Age & identity verification

Two separate outcomes (CLAUDE.md §8):

  • Age verification — does the person meet an age threshold? (age_verified, age_band e.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

  1. User clicks *Verify age* / *Verify identity* (GET /verify/age, GET /verify/identity).
  2. We create a verification_sessions row and redirect to the provider's hosted flow.
  3. 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.
  4. 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:

DriverStatus
mockImplemented. 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, idenfyNot 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_age require age_verified or identity_verified to join (unverified users are redirected to /verify/age).
  • Products with min_age are gated the same way by the store.