product · age verification

Prove age,
not identity.

Age-assurance rules in the UK and the EU converge on one engineering requirement: confirm an age attribute without collecting, storing, or linking identity. This verifier answers the question — over 18, yes or no — and withholds everything else, including anything two sites could use to recognise the same person.

01

The check is mandated. The database isn't.

The UK Online Safety Act places age-assurance duties on services in scope, and EU age-verification work centres on the same engineering brief: showing that a user is over an age threshold without disclosing other personal data. An age gate that builds a store of identity documents trades a compliance problem for a data-protection one.

Regulatory context only — not legal advice. Whether and how any duty applies to your service is a question for your counsel.

There is a second property on top of the brief: cross-site unlinkability. A standard wallet presentation can prove age; it does not by itself stop two relying parties from correlating the same user. That is the property this verifier is built around — demonstrated live below, with its limits stated.

02

Run a real zero-knowledge proof. Now.

Pick a date of birth for a stand-in holder. The demo mints a credential for that holder and runs a real Groth16/BN254 proof against the live verifier — no mock, which is why it takes about 10–15 seconds. In this demo the stand-in wallet and issuer run co-located on the demo server, so the date you type goes to the demo wallet to mint the credential; the verification transcript itself reveals no date of birth.

The public POST /verify/age endpoint mints and verifies a real Groth16/BN254 zero-knowledge proof end to end: the holder proves age_over_18 (and a per-relying-party scoped nullifier) in zero knowledge against a trusted issuer accumulator (a fixed shared set of K = 6,000,000 members folded into a depth-23 Poseidon-Merkle root), revealing no date of birth — the holder's secret is CSPRNG-random, not derived from the birthdate, so the DOB is not recoverable from the public transcript or the published root. It is reference-grade: the proving system uses a single-party deterministic trusted setup — not a multi-party ceremony — so it is not production-secure against a determined forger, and an over-18 proof is unlinkable among ~4,920,000 adults (the over-18 members of that 6,000,000-member set), a reference scale, not internet-scale. A real proof takes about 10–15 seconds — the honest cost of real ZK. [AGE-ZK-1]

03

Two sites check you. Neither can tell it was the same person.

Same stand-in holder, same birthdate, two different relying parties. Both accept — and each receives a different per-site nullifier over a freshly re-randomized proof. Run it and compare the values yourself.

The same holder presenting an age proof to two different relying parties (POST /verify/age, same date of birth, different relying_party_id) is accepted at both, yet produces two different per-relying-party nullifiers, and each show is a freshly re-randomized proof (byte-different every time) — so two colluding sites have nothing stable to correlate on. Every holder shares one published issuer accumulator root (a fixed set of K = 6,000,000 CSPRNG-seeded members folded into a depth-23 Poseidon-Merkle root): the root is an issuer-level constant identical for everyone, not a per-person handle, and the date of birth is not recoverable from the root or transcript. Each proof reveals only {shared issuer root, site-scoped nullifier, age ≥ 18}; a fail-closed replay guard rejects a captured transcript. Each is a real Groth16/BN254 zero-knowledge proof (~10–15 s per site). Reference-grade, honest residuals stated: unlinkable among ~4,920,000 adults (the over-18 members of a 6,000,000-member set; a reference scale, not internet-scale); a single-party deterministic trusted setup — not a multi-party ceremony; the replay guard and issuer/wallet co-location are demo-grade (in-memory); the self-contained demo maps an adult birthdate to one cached member; and two distinct same-age members share a nullifier with residual ~1/60,000 (about 0.0017%, which never de-anonymizes). The unlinkability property itself is modeled and machine-checked but partial overall — never "unlinkability-certified". [UNLINK-1]

Designed and machine-checked for unlinkability properties: identifier-hiding (T10a), audit/verifier-split (T10d) and nullifier-unlinkability / private-presentation theorems are discharged; verifier-view unlinkability, issuer hiding and selective-disclosure privacy are modeled with machine-verified observational-equivalence lemmas but remain partial overall. [FV-3] Theorem status →

04

Two modes, stated honestly.

Standards interop and cross-site unlinkability are different properties. Both are implemented — as two explicitly labelled modes, so you always know which one you are running.

mode 1

Wallet-backed — the standards path

A real OID4VP check of an SD-JWT VC or ISO mdoc age claim: structure, nonce binding, issuer signature. What it gives you: standards transport, no proprietary token. What it does not give you by itself: cross-site unlinkability.

Austria's vck library (A-SIT / the engine behind the ID-Austria Valera wallet) presented an SD-JWT PID to the PSDP verifier over OID4VP 1.0 Final (direct_post + DCQL); PSDP verified the issuer signature and the Key-Binding JWT and accepted (2026-06-08, reproducible harness). [WAL-2]

mode 2

Unlinkable — the ZK path

Adds the per-relying-party scoped nullifier and the predicate proof on top of the standards check. The relying party still learns only the boolean — and two relying parties hold different nullifier values for the same user. This is the property the wallet-backed mode alone does not deliver.

Reference ZK circuits additionally checked with Picus (Veridise, Z3) — "properly constrained" — and Circomspect (Trail of Bits) — "no issues found" — plus 14/14 negative-witness tests (circom reference circuits, April 2026 evidence package). [FV-4]

Post-quantum status, stated plainly. PQC-hybrid authentication (ML-DSA-65) + confidentiality (ML-KEM-768). The zero-knowledge layer is classical Groth16/BN254, with a de-risked (not yet live, not yet formally sound) transparent PQ-STARK backend on the roadmap. [PQ-1]

The standards path is tested against the official OpenID Foundation conformance suite (local run, reproducible): 319 of 332 test modules passed, 0 failed (11 warnings, 2 skipped), across OID4VCI, OID4VP and OpenID Federation test plans. Self-run evidence — not an OpenID Foundation certification. [OIDF-1] See the evidence →

05

One route. One answer.

Underneath is a single REST route — POST /verify/age returns the boolean, the per-site nullifier, and a receipt stating exactly which property was proven, and which wasn't. No CDN, no framework, no build step required on your side.

Fail-closed on mock proofs

In production mode (PSDP_PRODUCTION=1) the verifier refuses mock proofs — submissions are rejected with mock_crypto_rejected_in_production — and startup checks enforce production configuration before serving. [SEC-1]

What this is

PSDP is a reference implementation; production hardening is in progress. [SEC-3]

06

You price against exposure, not compute.

~$0.01–0.03
Indicative: per age-verification check, at volume [PRC-1]

Measured (2026-06-06, developer hardware): a real Groth16/BN254 verification costs ~7–22 ms of compute — on the order of 10–20 cents per million verifications — so verification compute is not a pricing floor. [PRC-5]

All figures indicative, not a rate card — pricing is finalised on the pilot call. You are pricing against a regulatory exposure and a repeated KYC check, not against a free login library.

07

What this is — and what it is not.

Read this before the pilot call. It is the same list we will say out loud.

This is the relying-party verifier and unlinkability engine — it is not the EU's consumer AV app, not a mandated wallet, and not the attestation issuer of record. A production deployment needs a partner issuer (and trust list), a partner wallet, and — for users with no credential — a separate age-estimation step.

The unlinkable ZK path is reference-grade: it hides the date of birth and delivers cross-site unlinkability among ~4,920,000 adults (the over-18 members of a 6,000,000-member set — a reference scale, not internet-scale), on a single-party deterministic trusted setup, not a ceremony. Its replay guard and co-located issuer/wallet are demo-grade (in-memory). Production needs a large real anonymity set and a trusted-setup ceremony.

We do not claim device, network, or IP anonymity; we don't vouch for the upstream enrolment behind the credential; and no conformity-assessment body has certified this component. The regulated service retains the legal duty. The ZK layer is classical Groth16/BN254 — not post-quantum. Demo paths are labelled as demos.

Pilot it against your age gate.

Bring one page you need to gate; we'll bring the verifier and the receipt. Engineering to engineering.