Verifier / Employer Identity
A genuine registered verifier/employer entity, layered additively on top of anonymous, account-free verification (unchanged and still the default path). Before this, “who verified this” was free text a visitor could optionally type — no identity, no way to distinguish “Acme Corp” from “acme corp” from a made-up name.
Modeled on two patterns already proven elsewhere in this codebase:
- Issuer registration lifecycle — public self-registration lands
PENDING_APPROVAL; an admin approves/rejects before anything privileged. - Holder authentication — passwordless magic-link sign-in,
HttpOnlysession cookie, no account enumeration.
Unlike either, this lives directly in core.verifier_accounts, owned by Core Engine — a verifier
account holds no keys and nothing outside Core Engine’s REST layer needs to resolve one.
Design decisions
- Admin-approval gated, unlike holder accounts. A new registration lands
PENDING_APPROVALand cannot sign in — the magic-link request silently no-ops for a pending/rejected/suspended account, exactly as it does for an unknown email, so approval status is never leaked via timing or response shape. - Cookie-delivered session, mirroring holder auth:
attest_verifier_session,HttpOnly; Secure; SameSite=Lax, 30-day sliding expiry. - No account enumeration —
POST /auth/magic-linkalways returns200. - Anonymous verification is untouched — still the default path. A signed-in verifier’s
organization name is authoritative and overrides client-supplied free text for that request; an
anonymous caller’s free text is untouched. Both land in the same
core.audit_logs.verifier_organizationcolumn. core.audit_logs.verifier_account_id(nullable FK) is the precise signal for “was this a registered verifier” —verifier_organizationalone can’t distinguish a registered org’s canonical name from a lucky free-text guess of the same string.
Registration → approval → session flow
Endpoints
Self-service auth under /api/v1/verifier/*; admin management under /api/v1/verifiers/*
(deliberately distinct prefix so the gateway can gate it on JWT without a wildcard collision).
| Method | Path | Auth | Notes |
|---|---|---|---|
| POST | /verifier/auth/register | Public | Lands PENDING_APPROVAL |
| POST | /verifier/auth/magic-link | Public | Always 200 |
| POST | /verifier/auth/magic-link/verify | Token in body | Sets session cookie |
| POST | /verifier/auth/logout | Session (optional) | Idempotent |
| GET | /verifier/profile | Session required | VerifierUser or 401 |
| GET | /verifiers | Admin JWT | Paginated list, status filter |
| GET | /verifiers/{id} | Admin JWT | Detail + all-time verification count |
| POST | /verifiers/{id}/approve | Admin JWT (ADMIN/OPERATOR) | → ACTIVE |
| POST | /verifiers/{id}/reject | Admin JWT (ADMIN/OPERATOR) | {reason} → REJECTED |
VerifierUser: {id, organizationName, contactName, email, status, createdAt}.
Schema
core.verifier_accounts (
verifier_id, organization_name, contact_name, email UNIQUE,
status CHECK IN ('PENDING_APPROVAL','ACTIVE','SUSPENDED','REJECTED') DEFAULT 'PENDING_APPROVAL',
rejection_reason, created_at, approved_at
)
core.verifier_magic_links (token_hash PK, verifier_id, created_at, expires_at, consumed_at)
core.verifier_sessions (session_id, verifier_id, token_hash UNIQUE, created_at, expires_at, revoked_at)
core.audit_logs.verifier_account_id -- nullable FK -> core.verifier_accounts(verifier_id)Gateway routes
| Route | Path | Auth | Limit |
|---|---|---|---|
verifier-registration | POST /verifier/auth/register | Public | 10/min |
verifier-auth | /verifier/auth/** | Public, cookie-based | 20/min |
verifier-profile | GET /verifier/profile | Public, cookie-based | 60/min |
verifier-admin-ops | /verifiers/** | JWT required | 30/min |
The existing public verification routes needed no change — Spring Cloud Gateway already forwards cookies untouched.
Known limitations
- No SSO/passkey option for verifiers — magic-link only, matching current scale. A larger enterprise employer needing SSO should follow the issuer portal’s OIDC/SAML pattern rather than retrofitting the cookie model here.
- The admin dashboard’s “verifications by organization” card doesn’t visually distinguish a registered-verifier row from a free-text one — both appear simply as organization names ranked by count. Deliberately deferred as low-value polish, not a functional gap.