A zero‑storage, privacy‑preserving age check that leverages banks’ existing KYC — with the user as the transport layer.
- 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.
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.
[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.
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 | — |
-
Merchant → User: Render a page that displays (a) a signed nonce
Nm
and (b) a WebAuthn challenge. -
Browser: Create a fresh, ephemeral WebAuthn credential (
Kt
); extractKt_public
. UV must be required. -
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))
-
Bank → User: After strong auth (login + 2FA), bank returns a signed age token (short TTL, e.g., 5 min).
-
User → Merchant: Paste the token +
Kt_public
back to the merchant. -
Merchant (stateless): Verify HMAC of
Nm
, verify bank signature & fields, match both hashes, verify a WebAuthn assertion withKt_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.
// 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
{
"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
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+
}
- 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.
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.
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
- 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 | 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. |
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.
- 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).
- 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?
- 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
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
MIT
interesting concept i hope it gets more recognition