Skip to content

Instantly share code, notes, and snippets.

@JWally
Last active September 3, 2025 15:14
Show Gist options
  • Save JWally/bf4681f79c0725eb378ec3c246cf0664 to your computer and use it in GitHub Desktop.
Save JWally/bf4681f79c0725eb378ec3c246cf0664 to your computer and use it in GitHub Desktop.

Bank‑Based Anonymous Age Verification (BAV)

A zero‑storage, privacy‑preserving age check that leverages banks’ existing KYC — with the user as the transport layer.


TL;DR

  • Banks sign an age claim, not an identity. They never learn which site you’re visiting.
  • Merchants verify a short‑lived token against their own nonce and a one‑time WebAuthn key. No database required.
  • The user copy/pastes the values between merchant and bank. No redirects, no OAuth, no trackers, no server‑to‑server calls. YOU see and control everything.

This is a framework / reference design to make anonymous age checks practical using institutions that already have KYC. It’s not “the one true standard” — it’s a clean baseline to critique, pilot, and iterate.


Why this exists

Current age‑verification options are either leaky (share PII), heavy (ID upload & storage), tracky (central IdPs), or pricey (per‑verification fees). Banks already know your age via KYC — we reuse that fact without revealing who you are or where you’re going.


System in one picture

[Merchant]
  │  (1) shows nonce Nm + challenge
  ▼
[User/Browser]  —(2) creates fresh WebAuthn key Kt—►
  │                                           
  ├──(3) copy two short strings────────────────► [Bank]
  │     - SHA256(Nm)
  │     - jkt(Kt_public) = SHA256(SPKI(Kt_public))
  │
  ◄────────────────────(4) bank returns signed age token (short‑lived)
  │
  └──(5) paste token + Kt_public back to merchant

Merchant verifies: token signature, matches hashes, and checks WebAuthn assertion with Kt_public.

Roles and data boundaries

Who Sees Never sees
Bank Your identity (already KYC’d), token issue time, the two hashes Merchant domain, URL cookies, user identity at merchant
Merchant Their own nonce, age threshold claim (e.g., over_18), Kt_public, token times User identity, bank account details
User Everything they copy/paste

High‑level flow (6 simple steps)

  1. Merchant → User: Render a page that displays (a) a signed nonce Nm and (b) a WebAuthn challenge.

  2. Browser: Create a fresh, ephemeral WebAuthn credential (Kt); extract Kt_public. UV must be required.

  3. User → Bank: Copy two short strings from the merchant page into the bank’s age‑check page:

    • merchant_nonce_hash = SHA256(Nm)
    • user_key_jkt = SHA256(SPKI(Kt_public))
  4. Bank → User: After strong auth (login + 2FA), bank returns a signed age token (short TTL, e.g., 5 min).

  5. User → Merchant: Paste the token + Kt_public back to the merchant.

  6. Merchant (stateless): Verify HMAC of Nm, verify bank signature & fields, match both hashes, verify a WebAuthn assertion with Kt_public (UV=1). If valid, accept age check.

No redirects. No iframes. No server‑to‑server calls. The user is the transport layer by design.


Reference objects

Merchant nonce (stateless)

// Create a minimal, verifiable nonce without a DB.
// Nm = base64url(payload) + "." + HMAC_SHA256(secret, payload)
const payload = {
  v: 1,                      // version
  ts: Date.now(),            // ms since epoch
  rnd: crypto.getRandomValues(new Uint8Array(16)) // 128‑bit entropy
};
const body = base64url(JSON.stringify(payload));
const mac = base64url(hmacSHA256(MERCHANT_SECRET, body));
const Nm = `${body}.${mac}`; // display Nm to the user

Minimal bank age token (JWT‑like; structure, not mandate)

{
  "ctx": "bank.age.v1",
  "iss": "bank.example",     
  "iat": 1735500000,
  "exp": 1735500300,          
  "age_over": { "18": true, "21": false },
  "merchant_nonce_hash": "HkJI…",
  "user_key_jkt": "XyZ1…",
  "jti": "abc123…"
}
// signed with ES256 over a canonical form; banks publish JWKs at:
// https://bank.example/.well-known/age-verification-key.json

Merchant verifier (skeleton)

async function verifyAge({Nm, token, Kt_public, webauthnAssertion}: Input): Promise<boolean> {
  // 1) Verify Nm HMAC & freshness
  const [body, mac] = Nm.split(".");
  if (!timingSafeEq(mac, hmacSHA256(MERCHANT_SECRET, body))) return false;
  const {v, ts} = JSON.parse(atoburl(body));
  if (v !== 1 || (Date.now() - ts) > 5*60*1000) return false; // 5 min

  // 2) Verify bank signature & token times
  const bankKey = await fetchJwkFor(token.iss, token.kid);
  if (!verifyES256(bankKey, token)) return false;
  if (now() < token.iat || now() > token.exp) return false;

  // 3) Match hashes
  if (sha256(Nm) !== token.merchant_nonce_hash) return false;
  if (sha256(spki(Kt_public)) !== token.user_key_jkt) return false;

  // 4) Verify WebAuthn assertion with UV required
  if (!verifyWebAuthn({publicKey: Kt_public, assertion: webauthnAssertion, requireUV: true})) return false;

  // 5) Policy: check acceptable issuers & age thresholds
  if (!TRUSTED_BANKS.has(token.iss)) return false;
  return token.age_over["18"] === true; // or 21+
}

Key properties

  • Anonymity by default: No merchant identifiers reach the bank; no bank identifiers reach the merchant.
  • One‑time keys: Kt is generated per check → prevents cross‑site correlation.
  • Stateless merchant: HMAC’d nonce + WebAuthn means no DB needed.
  • Short‑lived tokens: Minutes‑scale TTL + UV reduces resale value.

Optional anti‑resale add‑ons (off by default)

If a merchant wants stronger replay deterrence at some privacy cost, a bank may include opt‑in echoes:

  • ip_prefix (e.g., /24 for IPv4 or /56 for IPv6)
  • ua_hash (SHA256 of normalized UA string) Merchants can choose to enforce these; users/banks can decline.

“Every visit?” No — only once per merchant

On first success, offer the user a passkey account immediately. From then on, they just sign in with the passkey; your app’s account state records that age verification was done (no age or PII stored).

Benefits

  • One‑click returns, no re‑verification friction
  • Strong auth resistant to sharing
  • Compliance evidence at account creation time

Bank key publication (simple & cacheable)

  • Endpoint: https://<bank-domain>/.well-known/age-verification-key.json
  • Format: JWK Set (include current and grace‑period keys)
  • Caching: Reasonable TTL (e.g., 1h) + fast rotation path

Example JWK Set:

{ "keys": [{
  "kid": "2024-key-1",
  "kty": "EC",
  "crv": "P-256",
  "x": "",
  "y": "",
  "use": "sig",
  "alg": "ES256"
}]}

Threat model & mitigations (quick scan)

Threat Mitigation
Token resale/replay Short TTL; user_key_jkt binds token to Kt; WebAuthn UV assertion required; optional ip_prefix/ua_hash.
Bank learns merchant Only opaque hashes are sent; no origin, no referrer.
Merchant learns user identity Token contains only age threshold + hashes.
Token theft in transit User is the transport; token is useless without Kt + live UV assertion.
Key rotation breakage Fetch new JWKs on signature fail; keep old keys during grace.
Fake bank UI Users should navigate directly to their bank’s domain (or use a bank app). No redirects to spoof.
Shared devices/households UV enforces per‑person auth at issuance; merchants may add risk rules.

What this is / isn’t

Is: A lean, comprehensible pattern to decouple age claims from identity, reusing bank KYC without central trackers.

Isn’t: A mandate, a full spec, or legal advice. Jurisdictional rules vary; merchants must map “age threshold” fields to local requirements.


Implementation notes (for pilots)

  • Bank UX: a tiny page that accepts two strings, authenticates the user, and returns a signed token.
  • Merchant UX: a panel that shows Nm, a “Create passkey” button, and a paste box for the token.
  • Browser: use the native WebAuthn API; do not request attestation (privacy).

Open questions (for the community)

  • Incentives for banks (flat vs micro‑fees, CSR angle, or consortium model)?
  • Standardizing a community trusted‑banks registry (or allow‑lists per sector)?
  • How to treat teen accounts, guardianship, and regional thresholds (18/21/other)?
  • Should browsers offer a built‑in ‘Age Token’ flow to remove copy/paste friction while keeping the same privacy model?

Minimal spec sketch

  • Hash: SHA‑256
  • Sign: ES256 (P‑256)
  • Nonce: ≥128 bits entropy; HMAC‑SHA256 with secret; include version + timestamp
  • Token TTL: ≤5 minutes
  • WebAuthn: UV required; fresh credential per check; no attestation

Contributing

PRs welcome! Especially:

  • Tiny bank issuer reference server
  • Merchant verifier lib (TS)
  • Browser helper (nice copy/paste + WebAuthn UX)
  • Threat modeling & red‑team notes
  • Test vectors & conformance cases

License

MIT

@FlyingPhantom
Copy link

interesting concept i hope it gets more recognition

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment