Holder Authentication
Verified identity for the credential wallet — magic-link sign-in, account registration,
recovery-code issuance/redemption, session lifecycle, and WebAuthn passkey
registration/login (Yubico webauthn-server-core, Redis-backed ceremony store).
Design decisions
- Passwordless only — magic link (email) and WebAuthn passkeys are the two sign-in methods; a 24-word recovery code is the account-recovery fallback when both are lost. No password exists anywhere in this flow.
- Session delivery: HttpOnly cookie, not a bearer JWT. Deliberately differs from the issuer portal’s bearer-JWT pattern — the issuer portal is a business dashboard where the JS client needs to read token claims; the holder wallet is a PWA that may run on a shared/lower-trust device, so the session token itself should never be readable by JS.
- Magic-link delivery is real SMTP email, gated on
SMTP_HOST/SMTP_USERNAME/SMTP_PASSWORDbeing set. Unset (including local dev and CI) falls back to logging the link at INFO — an honest degradation, not a permanent stub. - No account enumeration —
magic-linkandpasskey/beginalways return200regardless of whether the email/account exists. - WebAuthn library: Yubico
webauthn-server-core— owns challenge generation, attestation/assertion verification, and sign-counter tracking; Core Engine only wires it tocore.holder_*persistence. - Ceremony state is server-side. Both registration and authentication are two-step (
begin→finish); thebeginresponse includes aceremonyIdthe client echoes back — never trust a client-supplied challenge.
Sign-in and recovery flows
Endpoints
All under POST/GET /api/v1/holder/auth/* and GET /api/v1/holder/profile — distinct from
/api/v1/issuer/auth/* (issuer-portal SSO). Routed through the API Gateway with no JWT filter
at the gateway layer — the session cookie is verified inside Core Engine.
| Endpoint | Auth | Response |
|---|---|---|
POST /auth/magic-link {email} | Public | 200 {sent: true, email} always |
POST /auth/magic-link/verify {token} | Public (token = credential) | 200 sets cookie, {success, user}; 401 {code: TOKEN_INVALID|TOKEN_EXPIRED|TOKEN_ALREADY_USED} |
POST /auth/passkey/begin {email} | Public | 200 {ceremonyId, publicKey} — empty allowCredentials if no passkeys registered |
POST /auth/passkey/finish {ceremonyId, credential} | Public (ceremony = credential) | 200 sets cookie; 401 {code: ASSERTION_INVALID|CEREMONY_EXPIRED} |
POST /auth/passkey/register/begin | Session required | 200 {ceremonyId, publicKey} — excludeCredentials from existing passkeys |
POST /auth/passkey/register/finish {ceremonyId, attestation, label} | Session required | 200 {success, credentialId}; 409 {code: CREDENTIAL_ALREADY_REGISTERED} |
GET /auth/passkeys | Session required | 200 Passkey[] — {id, label, addedAt, lastUsed} |
DELETE /auth/passkeys/{id} | Session required | 200; 404 if not caller’s own; 409 {code: LAST_PASSKEY_NO_RECOVERY} if it’s the last passkey with no recovery code issued |
POST /auth/recover {code} | Public (code = credential) | 200 sets cookie; 401 {code: RECOVERY_CODE_INVALID} — single-use |
POST /auth/recovery-code | Session required | 200 {code} — fresh 24-word code, invalidates any prior one |
POST /auth/register {name, email, phone?} | Public | 200 {success, userId} — does not start a session; 409 {code: EMAIL_ALREADY_REGISTERED} |
POST /auth/logout | Session required | 200 — clears cookie, deletes the session row (not just marks expired) |
GET /holder/profile | Session required | 200 HolderUser; 401 if no/expired/revoked session |
Shared shape
interface HolderUser {
id: string // core.holder_accounts.holder_id
name: string
email: string
did?: string // set once the holder has a wallet DID
phone?: string
createdAt: string // ISO-8601
}Session model
core.holder_sessions(session_id, holder_id, created_at, expires_at, revoked_at)— the browser cookie carries an opaque random value, stored hashed (SHA-256) in the session row.Secure; HttpOnly; SameSite=Lax, cookie nameattest_holder_session. The gateway passes it through untouched; only Core Engine resolves it.- Lifetime: 30 days, sliding (each request extends
expires_atif more than a day has elapsed since the last extension), capped by an absolute 90-day expiry.