DEV Community

Cover image for How we built identity verification for contractors: GPS scoring, revolving QR, and Google Wallet passes
Ryan Foster
Ryan Foster

Posted on

How we built identity verification for contractors: GPS scoring, revolving QR, and Google Wallet passes

We built Lynk ID — a contractor identity trust layer where a homeowner can scan a QR code or tap an NFC badge and instantly see who is at their door, without either party needing to download an app.

This post covers four pieces of the backend that were interesting to build:

  1. The composite trust scoring algorithm (and why we killed the NFC hard-gate)
  2. Google Wallet pass signing with RSA-SHA256 in Node.js
  3. The B2B webhook response envelope
  4. Revolving QR tokens for replay prevention

1. Composite trust scoring — GPS, NFC, biometrics

Initial versions of /api/v1/verify had a naive hard-gate:

// old — broken for QR-only partners
const approved = score >= 70 && nfcTap && deviceMatch;
Enter fullscreen mode Exit fullscreen mode

This meant any platform that didn't send an NFC tap always got approved: false, even with strong device fingerprint + biometric match. The fix was switching to a soft-penalty model with two valid approval paths.

// src/app/api/v1/verify/route.ts

type VerifyRequestBody = {
  endpointId?: string;
  referenceId?: string;
  subjectId?: string;
  signals?: {
    nfcTap?: boolean;
    deviceMatch?: boolean;
    biometricMatch?: boolean;
    gpsDistanceMeters?: number;
  };
};

function calculateVerification(body: VerifyRequestBody) {
  const nfcTap            = body.signals?.nfcTap === true;
  const deviceMatch       = body.signals?.deviceMatch === true;
  const biometricMatch    = body.signals?.biometricMatch === true;
  const gpsDistanceMeters = Math.max(0, Number(body.signals?.gpsDistanceMeters ?? 0));

  let score = 100;
  if (!nfcTap)                        score -= 30;  // NFC not required, but rewarded
  if (!deviceMatch)                   score -= 20;
  if (!biometricMatch)                score -= 15;
  if (gpsDistanceMeters > 100)        score -= 10;  // out of proximity tier 1
  if (gpsDistanceMeters > 500)        score -= 10;  // out of proximity tier 2

  score = Math.max(0, Math.min(100, score));

  const nfcPath = nfcTap;
  const qrPath  = deviceMatch && biometricMatch;

  // Approved on either path — NFC gives higher score/confidence
  const approved = score >= 60 && (nfcPath || qrPath);

  const confidence =
    nfcTap && deviceMatch && biometricMatch ? "high" :
    approved                               ? "medium" : "low";

  const riskLevel = score >= 85 ? "low" : score >= 65 ? "medium" : "high";

  return {
    approved,
    score,
    confidence,
    riskLevel,
    reasonCodes: [
      !nfcTap && !qrPath     ? "missing_nfc_tap"       : null,
      !deviceMatch            ? "device_mismatch"        : null,
      !biometricMatch         ? "biometric_unverified"   : null,
      gpsDistanceMeters > 100 ? "distance_out_of_range"  : null,
    ].filter(Boolean),
  };
}
Enter fullscreen mode Exit fullscreen mode

Score breakdown by scenario:

Signals present Score Approved Confidence
NFC + device + biometric 100 high
Device + biometric (QR path) 65 medium
NFC only, no biometric 55 low
Nothing 35 low

The GPS penalty is additive — being 600m away costs -20 total, which can flip a borderline medium confidence approval to denied. GPS is client-reported (we don't triangulate server-side), so it's one signal among many rather than a hard gate.


2. Google Wallet pass signing with RSA-SHA256

Google Wallet passes are issued by signing a JWT with your service account's private key. The google-auth-library JWT class handles OAuth tokens, but for the "Save to Wallet" link itself you sign directly — no OAuth round-trip needed.

// src/lib/wallet/google.ts

import { createSign, randomUUID } from "crypto";

function base64UrlEncode(input: Buffer | string) {
  const buffer = Buffer.isBuffer(input) ? input : Buffer.from(input);
  return buffer
    .toString("base64")
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/g, "");
}

function signRs256Jwt(
  header: Record<string, unknown>,
  payload: Record<string, unknown>,
  privateKey: string
) {
  const encodedHeader  = base64UrlEncode(JSON.stringify(header));
  const encodedPayload = base64UrlEncode(JSON.stringify(payload));
  const signingInput   = `${encodedHeader}.${encodedPayload}`;

  const signer = createSign("RSA-SHA256");
  signer.update(signingInput);
  signer.end();

  const signature        = signer.sign(privateKey);
  const encodedSignature = base64UrlEncode(signature);

  return `${signingInput}.${encodedSignature}`;
}

export function createGoogleWalletAddLink(input: {
  subjectId?: string;
  displayName: string;
  subtitle?: string;
  qrValue?: string;
}) {
  const { issuerId, serviceAccountEmail, privateKey, baseUrl } = getGoogleWalletConfig();

  const classId      = `${issuerId}.lynk_verified_v1`;
  const objectSuffix = sanitizeId(input.subjectId || randomUUID());
  const objectId     = `${issuerId}.${objectSuffix}`;
  const qrValue      = input.qrValue
    || `${baseUrl}/scan/${encodeURIComponent(input.subjectId || objectSuffix)}`;

  const claims = {
    iss: serviceAccountEmail,
    aud: "google",
    typ: "savetowallet",
    origins: [baseUrl],
    payload: {
      genericObjects: [
        {
          id: objectId,
          classId,
          state: "ACTIVE",
          cardTitle:  { defaultValue: { language: "en-US", value: input.displayName } },
          header:     { defaultValue: { language: "en-US", value: "Verified Identity" } },
          subheader:  { defaultValue: { language: "en-US", value: input.subtitle || "Contractor Trust Pass" } },
          barcode: {
            type:  "QR_CODE",
            value: qrValue,
          },
        },
      ],
    },
  };

  const token = signRs256Jwt({ alg: "RS256", typ: "JWT" }, claims, privateKey);

  return {
    addToWalletUrl: `https://pay.google.com/gp/v/save/${token}`,
    classId,
    objectId,
  };
}
Enter fullscreen mode Exit fullscreen mode

One sharp edge: environment variables often come in with escaped newlines (\\n) from .env files, Vercel's dashboard, or Docker secrets. We normalize before the PEM hits createSign:

function normalizePrivateKey(rawKey: string) {
  let k = (rawKey || "").trim();
  // strip surrounding quotes
  if ((k.startsWith('"') && k.endsWith('"')) || (k.startsWith("'") && k.endsWith("'"))) {
    k = k.slice(1, -1);
  }
  return k
    .replace(/\\r\\n/g, "\n")
    .replace(/\\n/g, "\n")
    .replace(/\r\n/g, "\n")
    .trim();
}
Enter fullscreen mode Exit fullscreen mode

We also validate the PEM boundaries before any signing attempt — a malformed key gives a cryptic OpenSSL error deep inside a Vercel edge log otherwise:

if (!privateKey.includes("BEGIN PRIVATE KEY") || !privateKey.includes("END PRIVATE KEY")) {
  throw new Error(
    "Invalid GOOGLE_WALLET_SERVICE_ACCOUNT_PRIVATE_KEY: include full PEM boundaries."
  );
}
Enter fullscreen mode Exit fullscreen mode

3. The B2B webhook / verify response envelope

When a rideshare platform or enterprise partner POSTs to /api/v1/verify, the response envelope looks like this:

{
  "verificationId": "a3c82f1e-9b47-4d2e-bf03-1234abcd5678",
  "apiVersion": "1.0.0",
  "endpointId": "lyft-dfw-staging",
  "referenceId": "trip_abc123",
  "subjectId": "usr_ryan_sideris",
  "result": {
    "approved": true,
    "score": 65,
    "confidence": "medium",
    "riskLevel": "medium",
    "reasonCodes": []
  },
  "billing": {
    "billable": true,
    "unit": "scan",
    "quantity": 1
  },
  "processedAt": "2026-03-07T14:22:01.003Z"
}
Enter fullscreen mode Exit fullscreen mode

Each event is also persisted asynchronously to a Firestore api_verifications collection using the REST API (no SDK import needed in Node.js edge functions):

async function persistVerificationEvent(event: Record<string, unknown>) {
  const url = `https://firestore.googleapis.com/v1/projects/${projectId}/databases/(default)/documents/api_verifications?key=${apiKey}`;

  await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ fields: toFirestoreFields(event) }),
  });
}
Enter fullscreen mode Exit fullscreen mode

Persistence failures are caught and logged without blocking the response — a slow Firestore write shouldn't add latency to a partner's real-time driver admission flow.

Rate limiting runs in-process (in-memory Map) keyed by a truncated SHA-256 hash of the API key. That keeps the key itself out of logs while still allowing per-key windowing:

function hashApiKey(rawKey: string): string {
  return createHash("sha256").update(rawKey).digest("hex").slice(0, 12);
}
Enter fullscreen mode Exit fullscreen mode

4. Revolving QR — replay prevention without a backend round-trip

A static QR URL is a screenshot attack waiting to happen. Our QR values rotate every 60 seconds using Math.floor(Date.now() / 60000) as a time-window token appended to the URL. The scanner-side validates that the token is ≤ 1 window old.

// src/app/dashboard/badges/page.tsx

const [timeWindow, setTimeWindow] = useState(
  () => Math.floor(Date.now() / 60000)
);
const [countdown, setCountdown] = useState(
  () => 60 - (Math.floor(Date.now() / 1000) % 60)
);

// 1-second tick — updates token and countdown display simultaneously
useEffect(() => {
  const tick = setInterval(() => {
    const now = Date.now();
    setTimeWindow(Math.floor(now / 60000));
    setCountdown(60 - (Math.floor(now / 1000) % 60));
  }, 1000);
  return () => clearInterval(tick);
}, []);
Enter fullscreen mode Exit fullscreen mode

The live QR display:

<QRCodeSVG
  value={`${badge.verifyUrl}?t=${timeWindow}`}
  size={120}
/>

{/* visual indicator */}
<div className="flex items-center gap-1.5 mt-2">
  <span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
  <span className="text-xs text-zinc-400">
    Refreshes in {countdown}s
  </span>
</div>
Enter fullscreen mode Exit fullscreen mode

The ?t= value is the Unix minute index — Math.floor(epoch_ms / 60000). The scan endpoint accepts a window of ±1 minute to account for clock skew between the contractor's phone and the server, making the effective replay window 60–120 seconds rather than infinite.

Why not a server-issued nonce? That would require a network round-trip from the contractor's phone before displaying the QR, which breaks the offline-capable, instant-show UX. The time-window approach gives most of the replay protection with zero latency.


What's next

  • Apple Wallet pkpass signing (PassKit + WWDR cert chain)
  • Server-side ?t= validation on the /verify/[tagUid] scan route
  • Background check status embedded in wallet pass subheader
  • Webhook HMAC signatures for partner event delivery

If any of this is useful, the API docs are at lynk-id.com/api and the V2 changelog is at lynk-id.com/changelog.

Top comments (0)