Skip to Content
ConceptsIdentity & AuthHolder Authentication

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_PASSWORD being 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-link and passkey/begin always return 200 regardless 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 to core.holder_* persistence.
  • Ceremony state is server-side. Both registration and authentication are two-step (begin → finish); the begin response includes a ceremonyId the 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.

EndpointAuthResponse
POST /auth/magic-link {email}Public200 {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}Public200 {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/beginSession required200 {ceremonyId, publicKey} — excludeCredentials from existing passkeys
POST /auth/passkey/register/finish {ceremonyId, attestation, label}Session required200 {success, credentialId}; 409 {code: CREDENTIAL_ALREADY_REGISTERED}
GET /auth/passkeysSession required200 Passkey[] — {id, label, addedAt, lastUsed}
DELETE /auth/passkeys/{id}Session required200; 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-codeSession required200 {code} — fresh 24-word code, invalidates any prior one
POST /auth/register {name, email, phone?}Public200 {success, userId} — does not start a session; 409 {code: EMAIL_ALREADY_REGISTERED}
POST /auth/logoutSession required200 — clears cookie, deletes the session row (not just marks expired)
GET /holder/profileSession required200 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 name attest_holder_session. The gateway passes it through untouched; only Core Engine resolves it.
  • Lifetime: 30 days, sliding (each request extends expires_at if more than a day has elapsed since the last extension), capped by an absolute 90-day expiry.