code - one of the 38 closed-set codes below. The UI branches on the
code, not the HTTP status - codes are stable, statuses are HTTP
conventions.
meta - caller-supplied context. Goes through a denylist of 13 sensitive
keys on toJSON(), so meta.password, meta.token, meta.secret,
meta.accessToken, etc. are stripped before wire emission.
origin - provider and flow that triggered the error. Goes to logs and
observability spans, never to the wire.
The error taxonomy
Session / auth state
Code
Status
Meaning
AUTH/UNAUTHENTICATED
401
No valid session in the request.
AUTH/SESSION_EXPIRED
401
Session token decodes but past expiresAt.
AUTH/SESSION_REVOKED
401
Session was revoked (sign-out, password change, hijack policy).
AUTH/AAL_INSUFFICIENT
403
The action requires a higher Authentication Assurance Level than the session has.
AUTH/STEP_UP_REQUIRED
403
Action requires a fresh re-auth within freshnessMs.
AUTH/MFA_REQUIRED
403
Session has not enrolled or passed the required MFA factor.
Credentials
Code
Status
Meaning
AUTH/INVALID_CREDENTIALS
401
Wrong password, wrong magic-link, wrong passkey. Same code, same timing for all four - defeats username enumeration.
AUTH/PASSKEY_MISMATCH
401
Passkey assertion was valid cryptographically but did not match any credential.
AUTH/EMAIL_NOT_VERIFIED
403
Action requires a verified email; user has not completed verification.
Rate-limit / lockout
Code
Status
Meaning
AUTH/RATE_LIMITED
429
Per-user or per-IP limiter tripped.
AUTH/LOCKED
423
Identity locked by hijack policy or admin.
AUTH/QUOTA_EXCEEDED
429
Out of magic-link, recovery, or API-key quota.
Providers
Code
Status
Meaning
AUTH/PROVIDER_FAILED
502
Upstream IdP returned a non-2xx (Google rejected the code, Apple JWKS unreachable, etc.).
The denylist applies to: password, passwordHash, secret, token,
accessToken, refreshToken, code, apiKey, privateKey, clientSecret,
webhookSecret, stateSigningSecret, jwt. These keys are removed from
meta before serialisation regardless of context - even in development.
Mapping codes to UI
The recommended pattern is to switch on code, not status:
consthandle=(err: AuthError)=>{ switch (err.code) { case'AUTH/INVALID_CREDENTIALS': return{ title:'Invalid email or password', tone:'error'asconst} case'AUTH/MFA_REQUIRED': return{ title:'Enter your authenticator code', tone:'info'asconst, action:'show-mfa'} case'AUTH/STEP_UP_REQUIRED': return{ title:'Confirm it's you', tone: 'info' as const, action: 'reauth' } case 'AUTH/RATE_LIMITED':
return { title:'Too many attempts. Try again in a minute.', tone:'warning'asconst} case 'AUTH/EMAIL_NOT_VERIFIED':
return { title:'Please verify your email first', tone:'info'asconst, action:'resend-verify'} case 'AUTH/EMAIL_TAKEN':
return { title:'That email is already in use', tone:'error'asconst} default:
return { title:'Authentication failed', tone:'error'asconst} }}