Identity Verification Module¶
Audience: maintainers and contributors working on document/selfie identity verification, the "ID Verified" trust badge, the per-member verification fee, or tenant registration policies that gate sign-up on identity.
Compliance note: this module handles government ID documents and biometric (selfie) data through third-party providers. Read the Privacy and data handling section before changing any code that stores, logs, or exports verification data.
Two distinct flows¶
Project NEXUS verifies identity in two separate places. They share the provider layer, the identity_verification_sessions table, the audit log, and the badge-grant helper, but they are entered differently and gated differently.
| Flow | Who | Entry point | Gate | Outcome |
|---|---|---|---|---|
| Optional / voluntary | Already-active members who want a trust badge | /verify-identity-optional (VerifyIdentityOptionalPage) |
identity_verification feature flag (default ON) |
Grants the id_verified badge |
| Registration-gated | New sign-ups, when the tenant's registration policy requires it | /verify-identity (VerifyIdentityPage) |
Tenant registration policy (verified_identity / government_id mode) |
Activates / approves / limits the new account |
This guide focuses primarily on the optional flow (the common case and the one tied to the identity_verification feature flag). The registration-gated flow is summarised in Registration-gated verification.
Feature gate (identity_verification)¶
The optional flow is gated by the per-tenant identity_verification feature flag. The platform default is ON (app/Services/TenantFeatureConfig.php — 'identity_verification' => true). Admins toggle it in Module Configuration.
When a tenant turns the flag off:
- The React route is blocked —
App.tsxwraps both/verify-identity-optionaland/verify-identity/callbackin<FeatureGate feature="identity_verification" redirect="/dashboard">. - The nav entry points are removed —
Navbar.tsxandMobileDrawer.tsxonly render the "Verify identity" item whenhasFeature('identity_verification')is true. - The backend rejects new/in-progress verification —
OptionalIdentityVerificationController::guardFeatureEnabled()returns a 403 (FEATURE_DISABLED) fromsaveDob,createPaymentIntent, andstartVerification. GET /v2/identity/statusis deliberately left ungated so existing "ID Verified" badges keep rendering after a tenant disables new verification. Read-only status stays available; only starting/progressing a verification is blocked.
The "ID Verified" badge — what actually drives the green tag¶
The green "Verified" / "ID Verified" tag keys on the id_verified member badge, granted by MemberVerificationBadgeService. It is not the same as users.is_verified (which tracks email verification). Do not conflate the two:
id_verifiedbadge → granted only after a document/selfie identity check passes and the verified name/DOB match the profile.- email/
is_verified→ set by the email-confirmation flow; unrelated to ID verification.
The badge is granted by OptionalIdentityVerificationController::grantIdVerifiedBadge(), which calls MemberVerificationBadgeService::grantBadge($userId, 'id_verified', ...). The grant is idempotent — every code path checks for an existing badge first.
Optional verification flow (start → provider → badge)¶
- Status — the page loads
GET /v2/identity/status, which reportshas_id_verified_badge,user_has_dob,fee_cents/fee_currency,payment_completed, and the latest session status. - Date of birth —
POST /v2/identity/save-dobsavesusers.date_of_birth. Validated server-side: required, a past date, and the user must be at least 16. DOB is locked once the badge is granted (returns 403dob_locked). DOB is later compared against the document's verified DOB. - Payment —
POST /v2/identity/create-paymentcreates a StripePaymentIntentfor the verification fee (default €5.00 / 500 cents; see The verification fee). Skipped entirely when the fee is 0 or already paid. - Start —
POST /v2/identity/startrequires DOB and (if fee > 0) completed payment, then creates a Stripe Identity session viaStripeIdentityProvider::createSession()and records a row inidentity_verification_sessions. Returns aredirect_url/client_tokenfor the user to complete the document + selfie check with Stripe. - Result — Stripe reports the outcome by webhook (preferred), in-app poll on status fetch, or the stuck-session cron (see Result reconciliation). On a real pass, the badge is granted and pass/fail emails are dispatched.
The provider for the optional flow is fixed to stripe_identity. Other registered providers (veriff, jumio, onfido, idenfy, mock) are available to the registration-gated flow via the tenant's registration policy.
Name / DOB match gate (critical invariant)¶
A document that Stripe reports as "verified" is not sufficient to grant the badge. The verified name and DOB returned by the provider (verified_outputs) must match the user's profile. OptionalIdentityVerificationController::checkNameDobMismatch() is the single source of truth for this gate and is applied identically by all three result paths (webhook, in-app poll, stuck-session cron). A mismatch downgrades a "passed" result to failed before any badge is granted, so the three paths cannot drift. Never bypass this check when adding a new result path.
The verification fee¶
Handled by IdentityVerificationPaymentService:
- Amount —
getFeeCents($tenantId)reads theidentity_verification_fee_centstenant setting (default500). Super-admins can set it to0for free verification viaPUT /v2/admin/super/identity/fee. - Currency — the tenant's configured currency (
TenantContext::getCurrency()); the status/intent responses surfaceeuras the displayed default. - Pay-once rule — once a user has any session with
payment_status = 'completed'for the tenant, retries after a failed verification skip payment. Enforced byIdentityVerificationSessionService::hasCompletedPaymentForTenant(). - Idempotency —
createPaymentIntent()uses a stable Stripe idempotency key (identity-{tenantId}-{userId}) so a client retry cannot double-charge. - Webhooks —
handlePaymentSucceeded()/handlePaymentFailed()update the session'spayment_statusand email the user. They early-return unless the PaymentIntent metadatanexus_type === 'identity_verification', so they never touch unrelated payments.
Providers¶
Providers implement IdentityVerificationProviderInterface and self-register in IdentityProviderRegistry by slug: stripe_identity, veriff, jumio, onfido, idenfy, and a dev-only mock (never registered in production). The interface abstracts hosted-redirect, embedded-SDK, webhook, and polling flows so the orchestration and controller code stay provider-agnostic.
Per-tenant provider credentials are managed by TenantProviderCredentialService and the admin endpoints under /v2/admin/identity/provider-credentials. Registration-policy provider config is encrypted at rest (AES-256-GCM, key derived from APP_KEY) by RegistrationPolicyService::encryptConfig().
Result reconciliation¶
Stripe Identity webhooks are not fully reliable, so three independent paths converge on the same status transitions and side effects (all gated by the name/DOB match):
| Path | Trigger | Code |
|---|---|---|
| Webhook | Provider posts to POST /v2/webhooks/identity/{provider_slug} (public, signature-verified, rate-limited) |
IdentityWebhookController::handleWebhook() → RegistrationOrchestrationService::handleVerificationResult() |
| In-app poll | User revisits the verification page; getStatus polls Stripe for any active session |
OptionalIdentityVerificationController::getStatus() |
| Stuck-session cron | Hourly nexus:identity:poll-stuck for sessions untouched for N minutes (default 5), created within 7 days |
App\Console\Commands\PollStuckIdentityVerifications (scheduled in bootstrap/app.php) |
The webhook handler verifies the provider signature, is idempotent against duplicate terminal events, and always returns 200 to the provider once received.
Tenant scoping¶
Every session and event row carries tenant_id. User-facing and listing queries filter by it; a few internal lookups instead key on globally-unique identifiers — the primary key (getById), the Stripe PaymentIntent id (findByPaymentIntentId), and the provider session id (findByProviderSession) — and resolve the tenant from the returned row (the webhook path supplies the tenant separately). The identity_verification_sessions table has FKs to both tenants and users with ON DELETE CASCADE. Email and notification rendering for results wraps the recipient in LocaleContext::withLocale() and pins TenantContext to the session's tenant before sending, so outcome emails render in the recipient's language and the correct tenant's branding/URLs.
Privacy and data handling¶
This is the compliance-sensitive part of the module.
- Data minimisation at the provider.
StripeIdentityProvider::createSession()does not pre-send the user's name/DOB to Stripe (only email is passed inprovided_details). The document name/DOB are retrieved fromverified_outputsafter the check completes, solely to run the profile-match gate. This minimisation applies to the identity-check session; the separate fee path (IdentityVerificationPaymentService::createPaymentIntent) does create a Stripe Customer with the user's name + email. - Result columns are stored as plaintext JSON — not encrypted. On
identity_verification_sessions,result_summaryandmetadataare written with a plainjson_encode()of the provider result (decision, risk score, checks, failure reason — not name/DOB or document images). Despite earlier intent they are not currently encrypted at rest; treat encrypting them (e.g. LaravelCrypt) as a known follow-up. The raw ID document and selfie are held by the provider (Stripe Identity), never in the NEXUS database. (By contrast, registration-policy provider credentials are AES-256-GCM encrypted — see Providers.) - Audit trail.
identity_verification_eventsrecords every state transition (registration_started…verification_passed/failed,admin_approved/rejected,account_activated, etc.) with actor type/id, IP, and user agent. Audit logging is best-effort and never breaks the main flow. - Retention.
IdentityVerificationSessionService::purgeOldSessions()deletes terminal sessions (passed/failed/expired/cancelled) older than the retention period (default 180 days).expireAbandoned()expires created/started sessions older than a threshold (default 72h). Theidentity_verification_eventsaudit trail is retained even when session rows are purged (the event FK isON DELETE SET NULL). - GDPR. Account deletion removes the user's
identity_verification_sessionsrows (GdprService, scoped byuser_id+tenant_id). The Article 15 data export returns only non-sensitive session metadata (id, provider slug, level, status, failure reason, timestamps) — never document images or biometric data. - Webhook security. The provider webhook signature is verified before any payload is processed; failures are logged and rejected with 403.
Admin / manual review (registration-gated flow)¶
When a tenant's registration policy uses a post-verification action of admin_approval, or a verification fails into a manual-review fallback, admins act on it via:
GET /v2/admin/identity/sessions— pending/active sessionsPOST /v2/admin/identity/sessions/{id}/approveand/reject→RegistrationOrchestrationService::adminReview()GET /v2/admin/identity/audit-log— the event audit trailGET /v2/admin/identity/provider-health,/providers,/provider-credentials— provider configuration and health
Registration-gated verification¶
Driven by tenant_registration_policies via RegistrationPolicyService and RegistrationOrchestrationService. Registration modes include open, open_with_approval, verified_identity, government_id, invite_only, and waitlist; verification levels are none, document_only, document_selfie, reusable_digital_id, manual_review; post-verification actions are activate, admin_approval, limited_access, reject_on_fail. When no policy row exists, the service falls back to the legacy registration_mode / admin_approval / email_verification tenant settings. The React entry point is /verify-identity, which polls GET /v2/auth/verification-status.
Key code, routes, and tables¶
- Routes — see
routes/api.php: optional flow/v2/identity/*(status,start,save-dob,create-payment); webhook/v2/webhooks/identity/{provider_slug}; registration status/v2/auth/verification-status; admin/v2/admin/identity/*and/v2/admin/super/identity/fee. Prefer the live route file and OpenAPI over copying endpoint tables. - Controllers —
OptionalIdentityVerificationController,IdentityWebhookController,IdentityProviderHealthController,RegistrationPolicyController. - Services (
app/Services/Identity/) —IdentityVerificationSessionService,IdentityVerificationEventService,IdentityVerificationPaymentService,IdentityProviderRegistry,IdentityVerificationProviderInterface, the provider adapters,RegistrationPolicyService,RegistrationOrchestrationService,TenantProviderCredentialService. PlusMemberVerificationBadgeServicefor the badge. - Tables —
identity_verification_sessions,identity_verification_events,tenant_registration_policies; theidentity_verification_fee_centstenant setting; theid_verifiedbadge inmember_verification_badges. - Frontend —
react-frontend/src/pages/settings/VerifyIdentityOptionalPage.tsx(optional),react-frontend/src/pages/auth/VerifyIdentityPage.tsx(registration), plus the native app modalmobile/app/(modals)/verify-identity.tsx.
Failure modes and recovery¶
| Failure | Behaviour | Recovery |
|---|---|---|
| User pays then closes the tab; webhook never arrives | Session stuck in created/started/processing |
Hourly nexus:identity:poll-stuck cron polls Stripe and applies the result; the in-app poll also recovers it on the user's next visit |
| Document passes but name/DOB ≠ profile | Result downgraded to failed with a "details don't match your profile" reason; no badge |
User corrects their profile and retries; payment is skipped (pay-once rule) |
| Webhook signature invalid | 403, payload not processed, warning logged | Re-check provider webhook secret in TenantProviderCredentialService / provider dashboard |
| Provider not configured for tenant | start returns 503 (SERVICE_UNAVAILABLE) |
Configure credentials via /v2/admin/identity/provider-credentials |
| Feature flag turned off | New verification blocked (403 FEATURE_DISABLED); existing badges still render |
Re-enable identity_verification in Module Configuration |
| Stripe payment status not yet synced | getStatus retrieves the PaymentIntent directly and reconciles payment_status |
Automatic; webhook also reconciles |
Tests¶
Key regression tests:
tests/Laravel/Feature/Controllers/OptionalIdentityVerificationControllerTest.php— DOB validation, fee/payment gating, feature-flag guard, badge grant.tests/Laravel/Feature/Controllers/IdentityWebhookControllerTest.php— signature verification, idempotency, name/DOB mismatch downgrade, badge grant.tests/Laravel/Unit/Services/IdentityVerificationPaymentServiceTest.php— fee resolution, pay-once rule, idempotency key.tests/Laravel/Unit/Services/Identity/IdentityVerificationSessionServiceTest.php,IdentityVerificationEventServiceTest.php,StripeIdentityProviderTest.php,IdentityProviderRegistryTest.php.
React tests live alongside the pages: react-frontend/src/pages/settings/VerifyIdentityOptionalPage.test.tsx and react-frontend/src/pages/auth/VerifyIdentityPage.test.tsx.