Skip to content

Instantly share code, notes, and snippets.

@JWally
Last active August 5, 2026 11:52
Show Gist options
  • Select an option

  • Save JWally/bf4681f79c0725eb378ec3c246cf0664 to your computer and use it in GitHub Desktop.

Select an option

Save JWally/bf4681f79c0725eb378ec3c246cf0664 to your computer and use it in GitHub Desktop.
Bank-attested, privacy-preserving age verification using KYC, WebAuthn, and user-mediated transport

Bank-Attested Age Verification (BAV)

A privacy-preserving, minimal-disclosure age check that reuses banks' existing KYC, with the user as the transport layer.


TL;DR

  • 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.


Why this exists

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.


System in one picture

[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.

Roles and data boundaries

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.


High-level flow

  1. Merchant -> User: Create an authenticated nonce Nm containing a purpose, issue time, expiry, and at least 128 bits of randomness. Start a WebAuthn registration ceremony.

  2. Browser: Create a fresh, single-purpose WebAuthn credential Kt with user verification required. The private key remains in the authenticator; the merchant verifies the registration response and obtains Kt_public.

  3. 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.

  4. 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.

  5. User -> Merchant: Paste the compact token back into the merchant page.

  6. Browser -> Merchant: Perform a WebAuthn assertion whose challenge is bound to Nm and 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.

  7. 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 + jti in 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.


Reference objects

Merchant nonce

// 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}`;

User-carried bank request

{
  "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.

Bank age token

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.

Merchant verifier (skeleton)

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.


Key properties

  • 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 + jti in 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.

"Every visit?" No

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.


Trusted issuer and key publication

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 kid only within that issuer's configured JWK Set.
  • Caching: Cache keys with a reasonable TTL. An unknown kid may 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 model and mitigations

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.

What this is / isn't

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.


Implementation notes for pilots

  • 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 plus jti, 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.

Open questions

  • 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?

Minimal profile sketch

  • Hash: SHA-256; binary values encoded with unpadded Base64URL
  • Credential-key binding: SHA-256 over DER-encoded SPKI, named credential_key_hash (not jkt)
  • Token: Compact JWS/JWT with fixed typ, explicit alg, exact iss, generic protocol aud, 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

Useful next steps

  • 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

License

MIT

@Zfentom

Zfentom commented Sep 3, 2025

Copy link
Copy Markdown

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