Skip to Content
ConceptsIdentity & AuthVerifier / Employer Identity

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, HttpOnly session 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_APPROVAL and 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-link always returns 200.
  • 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_organization column.
  • core.audit_logs.verifier_account_id (nullable FK) is the precise signal for “was this a registered verifier” — verifier_organization alone 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).

MethodPathAuthNotes
POST/verifier/auth/registerPublicLands PENDING_APPROVAL
POST/verifier/auth/magic-linkPublicAlways 200
POST/verifier/auth/magic-link/verifyToken in bodySets session cookie
POST/verifier/auth/logoutSession (optional)Idempotent
GET/verifier/profileSession requiredVerifierUser or 401
GET/verifiersAdmin JWTPaginated list, status filter
GET/verifiers/{id}Admin JWTDetail + all-time verification count
POST/verifiers/{id}/approveAdmin JWT (ADMIN/OPERATOR)→ ACTIVE
POST/verifiers/{id}/rejectAdmin 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

RoutePathAuthLimit
verifier-registrationPOST /verifier/auth/registerPublic10/min
verifier-auth/verifier/auth/**Public, cookie-based20/min
verifier-profileGET /verifier/profilePublic, cookie-based60/min
verifier-admin-ops/verifiers/**JWT required30/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.