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

Published: (March 7, 2026 at 11:45 PM EST)
7 min read
Source: Dev.to

Source: Dev.to

Lynk ID Backend Highlights

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

Early versions of /api/v1/verify used a naïve hard‑gate:

// old — broken for QR‑only partners
const approved = score >= 70 && nfcTap && deviceMatch;

Any platform that didn’t send an NFC tap was automatically rejected, even when the device fingerprint and biometric match were strong.
The fix was to switch 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),
  };
}

Score breakdown by scenario

Signals presentScoreApprovedConfidence
NFC + device + biometric100high
Device + biometric (QR path)65medium
NFC only, no biometric55low
Nothing35low

The GPS penalty is additive – being 600 m 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 just 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 private key. The google-auth-library JWT class handles OAuth tokens, but for the “Save to Wallet” link 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,
  };
}

Sharp edge: the environment must expose the private key in PEM format; loading it from a secret manager or environment variable is recommended to avoid accidental commits.

3. B2B webhook response envelope

(Content omitted for brevity – the original post includes the JSON envelope schema, versioning strategy, and error‑handling conventions.)

4. Revolving QR tokens for replay prevention

(Content omitted for brevity – the original post explains how a short‑lived, HMAC‑signed token is embedded in the QR payload, how the server validates it, and how rotation mitigates replay attacks.)

Normalizing PEM Keys

Environment variables often come in with escaped newlines (\\n) from .env files, Vercel’s dashboard, or Docker secrets. We normalize the key before it reaches 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();
}

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

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."
  );
}

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"
}

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) })
  });
}

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);
}

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);
}, []);

Live QR display

{/* visual indicator */}
<div>
  Refreshes in {countdown}s
</div>

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?
A nonce would require a network round‑trip from the contractor’s phone before displaying the QR, breaking 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 and the V2 changelog is at .

0 views
Back to Blog

Related posts

Read more »

Authentication vs Authorization

Authentication Authentication is about confirming a user's identity. It is the process the system uses to verify that you are who you claim to be. A common exa...