Age & identity verification
Two separate outcomes (CLAUDE.md §8):
- Age verification — does the person meet an age threshold? (
age_verified,age_bande.g.18+,21+) - Identity verification — is the person who they claim to be? (
identity_verified)
What we store
Only outcomes on users: verification_status (unverified | pending | age_verified | identity_verified | failed | manual_review), age_verified_at, identity_verified_at, verification_provider, verification_reference (the provider's session id), age_band. Attempts are tracked in verification_sessions (reference, kind, status).
Never stored: document images or numbers, dates of birth, selfies, or the raw webhook payload (webhook_events.payload is left NULL for the verification provider; only a hash is kept for idempotency).
Flow
- User clicks *Verify age* / *Verify identity* (
GET /verify/age,GET /verify/identity). - We create a
verification_sessionsrow and redirect to the provider's hosted flow. - The provider calls
POST /webhooks/verification(signed). We verify the signature, store the event once by(provider, event_id), update the session and the user's outcome fields, write an audit entry and notify the user. - The user returns to
/verify/return, which only *displays* the current status. The browser can never set a status.
A stronger outcome is never downgraded (e.g. a later failed age check does not remove identity_verified).
Providers
Configured with VERIFICATION_PROVIDER in .env:
| Driver | Status |
|---|---|
mock | Implemented. Dev-only hosted page at /verify/mock/{reference} lets an operator pick *Approve 18+ / Approve 21+ / Fail / Manual review*; it sends a real signed webhook to our endpoint. Disabled when APP_ENV=production. |
stripe_identity, persona, veriff, idenfy | Not integrated. The registry throws "not configured". The owner must confirm which provider to use (rule 8 — "iZobe" could not be verified). |
Mock webhook contract (what a real adapter must normalise to):
POST /webhooks/verification
X-Verification-Signature: hex(HMAC-SHA256(raw_body, VERIFICATION_WEBHOOK_SECRET))
{"event_id":"evt_…","reference":"…","status":"age_verified","age_band":"18+"}
Gating
- Communities with
min_agerequireage_verifiedoridentity_verifiedto join (unverified users are redirected to/verify/age). - Products with
min_ageare gated the same way by the store.