A privacy-preserving, minimal-disclosure age check that reuses banks' existing KYC, with the user as the transport layer.
- A bank authenticates an existing customer and signs a short-lived age-threshold claim - not their name, date of birth, account number, or other identity details.
- The merchant binds that claim to its own nonce and a fresh WebAuthn credential, then atomically consumes the claim in a short-lived, single-use replay ledger. The reference verifier uses Redis.
- The user carries the request and response between the merchant and bank. The handoff is explicit and user-controlled: no redirect, OAuth flow, iframe, third-party tracker, or per-verification callback is required.
- The result is strong evidence that an age-qualified bank customer authorized a claim for a key the presenter controls. It raises the cost of sharing substantially, but does not make deliberate delegation impossible.
This is a reference design for making privacy-preserving age checks practical with institutions that already perform KYC. It is a baseline to critique, pilot, and improve - not a proposed universal standard.
Most age checks either disclose identity, require storing an ID image, centralize tracking, or charge per verification. Banks already hold date-of-birth information through KYC. This design asks them to attest to a requested age threshold and nothing more.
[Merchant]
| (1) creates authenticated nonce Nm + WebAuthn registration challenge
v
[User/Browser] --(2) creates fresh, single-purpose credential Kt-->
| private key remains in the authenticator
|
+--(3) copy one compact request------------------------------> [Bank]
| - SHA256(Nm)
| - SHA256(DER-SPKI(Kt_public))
| - requested threshold
|
<--------------------(4) bank returns signed age claim (short TTL)
|
+--(5) paste claim; perform WebAuthn assertion--------------> [Merchant]
Merchant verifies the bank signature, request and key bindings, WebAuthn assertion,
and then atomically consumes issuer + jti in Redis before creating an age session.
| Who | Receives through the protocol | Does not receive through the protocol |
|---|---|---|
| Bank | Its authenticated customer, requested threshold, issue time, merchant-nonce hash, credential-key hash | Merchant identity, origin, URL, cookies, or merchant-side account |
| Merchant | Trusted bank issuer, age-threshold result, credential public key, token times, its own nonce | Customer name, birthdate, bank account, or other bank-customer identifier |
| User | Every value carried between the two services | - |
The merchant necessarily learns which trusted bank issued the claim. The bank and merchant could also correlate a transaction if they deliberately cooperate or combine external timing and network observations.
-
Merchant -> User: Create an authenticated nonce
Nmcontaining a purpose, issue time, expiry, and at least 128 bits of randomness. Start a WebAuthn registration ceremony. -
Browser: Create a fresh, single-purpose WebAuthn credential
Ktwith user verification required. The private key remains in the authenticator; the merchant verifies the registration response and obtainsKt_public. -
User -> Bank: Copy one compact request containing:
merchant_nonce_hash = BASE64URL(SHA256(Nm))credential_key_hash = BASE64URL(SHA256(DER-SPKI(Kt_public)))requested_age_over = 18(or the locally required threshold)
The request may be Base64URL-encoded for easier transport, but encoding is not encryption.
-
Bank -> User: Authenticate the specific customer using the bank's normal controls, evaluate the requested predicate against that customer's KYC birthdate, and return a compact signed JWS/JWT with a short lifetime (for example, five minutes). The bank emits only the requested positive threshold claim.
-
User -> Merchant: Paste the compact token back into the merchant page.
-
Browser -> Merchant: Perform a WebAuthn assertion whose challenge is bound to
Nmand the exact bank token. The browser submits the token,Nm, credential ID/public key (or authenticated registration state already held by the merchant), and assertion. -
Merchant: Validate its nonce, resolve the claimed issuer through a local allowlist, verify the bank signature and token claims, match both hashes, and verify the complete WebAuthn assertion. Immediately before creating the age-verified session, atomically consume
issuer + jtiin Redis. A second presentation fails.
There are no redirects, iframes, OAuth exchanges, or per-verification bank-to-merchant callbacks. Public signing keys are fetched separately from preconfigured issuer endpoints and cached.
// Nm = BASE64URL(payload) + "." + BASE64URL(HMAC_SHA256(secret, body))
const now = Math.floor(Date.now() / 1000);
const payload = {
v: 1,
ctx: "bank.age.request.v1",
iat: now,
exp: now + 300,
rnd: base64url(randomBytes(16)) // 128 bits
};
const body = base64url(JSON.stringify(payload));
const mac = base64url(hmacSHA256(MERCHANT_SECRET, utf8(body)));
const Nm = `${body}.${mac}`;{
"merchant_nonce_hash": "HkJI...",
"credential_key_hash": "XyZ1...",
"requested_age_over": 18
}The user does not need to interpret this object, and it is not secret. Base64URL makes it easier to move; it does not encrypt it. The hashes bind the eventual bank claim to this merchant request and this credential without sending either underlying value to the bank.
Use a standard compact JWS carrying JWT claims rather than a custom "JWT-like" format.
Protected header:
{
"alg": "ES256",
"kid": "2026-key-1",
"typ": "bank-age+jwt"
}Payload:
{
"ctx": "bank.age.v1",
"iss": "https://age.bank.example",
"aud": "urn:bank-age-verification:verifier:v1",
"iat": 1774800000,
"nbf": 1774800000,
"exp": 1774800300,
"age_over": 18,
"merchant_nonce_hash": "HkJI...",
"credential_key_hash": "XyZ1...",
"jti": "128-bits-or-more-of-randomness"
}age_over: 18 is a positive assertion that the authenticated customer meets that threshold. A bank should not include unrequested thresholds that reveal a narrower age range.
aud identifies this verifier class, not an individual merchant. The authenticated merchant nonce provides recipient binding without disclosing the merchant to the bank.
const MAX_TOKEN_TTL_SECONDS = 300;
const CLOCK_SKEW_SECONDS = 30;
const TRUSTED_ISSUERS = new Map([
["https://age.bank.example", {
issuer: "https://age.bank.example",
jwksUrl: "https://age.bank.example/.well-known/age-verification-jwks.json",
algorithm: "ES256"
}]
]);
async function verifyAge(input: Input): Promise<boolean> {
const { Nm, compactToken, credentialPublicKey, webauthnAssertion } = input;
const now = epochSeconds();
// 1) Verify the merchant nonce before trusting any binding to it.
const [body, encodedMac] = Nm.split(".");
if (!body || !encodedMac) return false;
const expectedMac = hmacSHA256(MERCHANT_SECRET, utf8(body));
if (!timingSafeEqual(base64urlDecode(encodedMac), expectedMac)) return false;
const nonce = JSON.parse(utf8(base64urlDecode(body)));
if (nonce.v !== 1 || nonce.ctx !== "bank.age.request.v1") return false;
if (nonce.iat > now + CLOCK_SKEW_SECONDS) return false;
if (nonce.exp < now - CLOCK_SKEW_SECONDS) return false;
if (nonce.exp - nonce.iat > MAX_TOKEN_TTL_SECONDS) return false;
// 2) Decode only enough to select from a closed local issuer map.
// Header and payload remain untrusted until signature verification succeeds.
const untrusted = decodeCompactJwtWithoutVerification(compactToken);
const issuer = TRUSTED_ISSUERS.get(untrusted.payload.iss);
if (!issuer) return false; // Reject before making any network request.
if (untrusted.protectedHeader.alg !== issuer.algorithm) return false;
if (untrusted.protectedHeader.typ !== "bank-age+jwt") return false;
if (!isBoundedKeyId(untrusted.protectedHeader.kid)) return false;
// jwksUrl comes from local configuration, never from token fields.
// An unknown kid may cause one rate-limited refresh from this fixed URL.
const bankKey = await getCachedJwk(issuer.jwksUrl, untrusted.protectedHeader.kid);
const token = await verifyCompactJws(compactToken, bankKey, {
algorithms: [issuer.algorithm],
issuer: issuer.issuer,
audience: "urn:bank-age-verification:verifier:v1",
typ: "bank-age+jwt"
});
if (!token) return false;
// 3) Validate context, lifetime, policy, and both request bindings.
if (token.ctx !== "bank.age.v1") return false;
if (token.iat > now + CLOCK_SKEW_SECONDS) return false;
if (token.nbf > now + CLOCK_SKEW_SECONDS) return false;
if (token.exp < now - CLOCK_SKEW_SECONDS) return false;
if (token.exp - token.iat > MAX_TOKEN_TTL_SECONDS) return false;
if (!isValidRandomId(token.jti, 16)) return false;
if (token.age_over !== MERCHANT_REQUIRED_AGE) return false;
if (base64url(sha256(utf8(Nm))) !== token.merchant_nonce_hash) return false;
if (base64url(sha256(derSpki(credentialPublicKey))) !== token.credential_key_hash) return false;
// 4) Bind a live WebAuthn assertion to this nonce and this exact token.
const expectedChallenge = base64url(sha256(utf8(
`bank.age.assert.v1\0${Nm}\0${compactToken}`
)));
const assertionValid = await verifyWebAuthnAssertion({
credentialPublicKey,
assertion: webauthnAssertion,
expectedChallenge,
expectedOrigin: MERCHANT_ORIGIN,
expectedRpId: MERCHANT_RP_ID,
requireUserVerification: true
});
if (!assertionValid) return false;
// 5) Consume only after every proof check, immediately before session issuance.
// SET NX makes concurrent presentations race-safe. Fail closed if Redis is unavailable.
const replayId = base64url(sha256(utf8(`${token.iss}\0${token.jti}`)));
const replayKey = `bank-age:used:${replayId}`;
const consumed = await redis.set(replayKey, "1", {
NX: true,
EXAT: token.exp + CLOCK_SKEW_SECONDS
});
if (consumed !== "OK") return false;
return true;
}All parsing, network, cryptographic, and Redis errors fail closed. The WebAuthn verifier must validate the ceremony type, expected challenge, expected origin, RP ID hash, signature, credential ID, and user-verification flag. Checking only the signature or UV bit is insufficient.
- Minimal disclosure: The bank receives no merchant identity in the issuance request. The merchant receives no customer or account identifier; it necessarily learns which trusted issuer signed the claim.
- User-mediated transport: There is no per-verification backchannel between bank and merchant. The user controls the handoff.
- Fresh credential per check: A new WebAuthn credential prevents a stable credential from becoming a cross-site identifier.
- Holder binding: The token is bound to the credential public key and requires a live, user-verified WebAuthn assertion. A stolen token alone is not enough.
- Single use: The merchant atomically consumes the token's
issuer + jtiin a short-lived replay ledger. The reference verifier uses Redis; concurrent or later presentation of the same token fails. - Ephemeral state: Replay records disappear after the token lifetime. The merchant needs no persistent verification database or identity record for the one-time check.
- Short exposure window: A minutes-scale token lifetime limits the value of intercepted or abandoned claims.
- Practical assurance, not identity proof: Bank authentication plus possession of the bound key makes casual borrowing and resale difficult. Deliberate authorization of another person's device remains possible.
After a successful verification, the merchant creates its own age-verified session. It may also offer the user a separate long-lived account passkey and record that a particular threshold was satisfied under a particular protocol version and assurance policy.
That avoids repeating the bank flow on every visit without requiring the merchant to store a name, birthdate, ID image, or bank-account identifier. Retention and compliance requirements remain merchant- and jurisdiction-specific.
Each merchant maintains a closed local map from an exact issuer identifier to a fixed HTTPS JWKS endpoint and permitted algorithm. The unverified iss, kid, jku, or other token fields must never supply an arbitrary fetch destination.
- Onboarding: Verify the bank and configure its exact issuer, JWKS URL, and permitted algorithm.
- Runtime: Reject unknown issuers before network activity. Select
kidonly within that issuer's configured JWK Set. - Caching: Cache keys with a reasonable TTL. An unknown
kidmay trigger one rate-limited refresh from the configured endpoint. - Rotation: Publish current and grace-period keys. Retain old verification keys at least as long as the maximum token lifetime and clock-skew allowance.
- Transport: Require HTTPS and tightly control redirects. Key retrieval is infrastructure maintenance, not a per-verification callback carrying user data.
Privacy-sensitive deployments should refresh keys on a schedule or through a neutral registry or CDN rather than synchronously for each verification. That reduces timing correlation between issuance and key retrieval.
Example JWK Set:
{
"keys": [{
"kid": "2026-key-1",
"kty": "EC",
"crv": "P-256",
"x": "...",
"y": "...",
"use": "sig",
"alg": "ES256"
}]
}| Threat | Mitigation or boundary |
|---|---|
| Stolen age token | The token is bound to a credential key and is unusable without a valid WebAuthn assertion. |
| Replay of a complete valid response | Atomically consume issuer + jti in Redis using SET NX EX; reject subsequent use. |
| Forged or attacker-selected issuer | Resolve iss only through a local allowlist mapping trusted issuers to fixed JWKS endpoints. Reject unknown issuers before network access and pin the permitted algorithm. |
| WebAuthn assertion replay or substitution | Verify ceremony type, expected challenge, origin, RP ID hash, credential ID, signature, and UV flag, then consume the associated claim once. |
| Bank learns merchant | The issuance request contains opaque hashes and a requested threshold, not an origin, referrer, or merchant identifier. External timing correlation and collusion are outside this guarantee. |
| Merchant learns customer identity | The signed claim contains an issuer, threshold, key binding, nonce binding, and times - but no name, birthdate, account number, or stable customer identifier. |
| Deliberate adult-to-minor delegation | Not cryptographically eliminated. Bank authentication and live key possession provide a high-confidence age signal and resist casual sharing; merchants may apply additional risk controls where required. |
| Minor, joint, or guardian account ambiguity | The bank evaluates the KYC birthdate of the specifically authenticated natural person, not merely the account owner or household. |
| Bank-key rotation | Cache keys from the configured endpoint, retain retired keys for a grace period, and allow a rate-limited refresh for an unknown kid. |
| Fake bank interface | Encourage direct navigation or the bank's native app. User-mediated transport removes automatic redirects but does not eliminate phishing. |
Is: A lean reference architecture for decoupling an age-threshold claim from customer identity, using bank KYC without introducing a central verification intermediary.
Isn't: A proof of formal anonymity or unlinkability, proof that the presenter and bank customer are the same natural person, a universal standard, or legal advice. Jurisdictional rules vary, and merchants must map threshold claims and retained evidence to their own requirements.
- Bank UX: Accept one compact request, authenticate the specific customer, clearly display the requested threshold, and return one signed positive claim.
- Merchant UX: Provide a "Create verification credential" button, one copyable request, and one paste box for the bank token. QR or native-app handoff can reduce friction without changing the protocol.
- Browser: Use the native WebAuthn API with user verification required. Do not request attestation by default; that preserves privacy but does not establish hardware provenance.
- Replay ledger: Use an atomic Redis
SET key value NX EX ttl. Key by issuer plusjti, retain it through token expiry plus clock skew, and fail closed if the ledger is unavailable. - Logging: Avoid retaining request blobs, tokens, credential keys, or bank/customer correlation data beyond documented operational and compliance needs.
- What incentive and liability model makes issuance attractive to banks?
- How should trusted-issuer governance, assurance levels, revocation, and incident response work?
- How should banks represent teen, joint, guardian, and regional threshold policies?
- Should a standardized browser or digital-credential handoff replace copy/paste while preserving the same data boundaries?
- What evidence must a merchant retain for each jurisdiction without recreating an identity database?
- Hash: SHA-256; binary values encoded with unpadded Base64URL
- Credential-key binding: SHA-256 over DER-encoded SPKI, named
credential_key_hash(notjkt) - Token: Compact JWS/JWT with fixed
typ, explicitalg, exactiss, generic protocolaud, required claims, and no remote key URL controlled by token data - Sign: ES256 (P-256) in this profile; merchants pin the permitted algorithm per issuer
- Nonce: At least 128 bits of entropy; HMAC-SHA256 authenticated; versioned context, issue time, and expiry
- Token lifetime: At most five minutes plus a narrowly defined clock-skew allowance
- Replay: Atomic one-time ledger on a hash of
issuer + jti; the reference verifier uses Redis with expiration through token acceptance - WebAuthn: Fresh credential per check, user verification required, no attestation by default, complete registration and assertion verification
- Standards: JWS (RFC 7515), JWT (RFC 7519), JWT Best Current Practices (RFC 8725), and WebAuthn
- Tiny bank issuer reference server
- Merchant verifier library for TypeScript
- Copy/paste, QR, and native-app handoff helpers
- Test vectors and conformance cases
- Threat-model and red-team review
MIT
interesting concept i hope it gets more recognition