Skip to main content

Errors

WIP

Every AUTH/* error code, the HTTP status it maps to, and the wire-safe meta redaction policy.

AuthError

Every typed error duck-auth raises is an AuthError:

import { AuthError } from '@gentleduck/auth/core'

throw new AuthError('AUTH/INVALID_CREDENTIALS', {
  email,
}, {
  providerId: 'password',
  flow: 'signin',
})
  • 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

CodeStatusMeaning
AUTH/UNAUTHENTICATED401No valid session in the request.
AUTH/SESSION_EXPIRED401Session token decodes but past expiresAt.
AUTH/SESSION_REVOKED401Session was revoked (sign-out, password change, hijack policy).
AUTH/AAL_INSUFFICIENT403The action requires a higher Authentication Assurance Level than the session has.
AUTH/STEP_UP_REQUIRED403Action requires a fresh re-auth within freshnessMs.
AUTH/MFA_REQUIRED403Session has not enrolled or passed the required MFA factor.

Credentials

CodeStatusMeaning
AUTH/INVALID_CREDENTIALS401Wrong password, wrong magic-link, wrong passkey. Same code, same timing for all four - defeats username enumeration.
AUTH/PASSKEY_MISMATCH401Passkey assertion was valid cryptographically but did not match any credential.
AUTH/EMAIL_NOT_VERIFIED403Action requires a verified email; user has not completed verification.

Rate-limit / lockout

CodeStatusMeaning
AUTH/RATE_LIMITED429Per-user or per-IP limiter tripped.
AUTH/LOCKED423Identity locked by hijack policy or admin.
AUTH/QUOTA_EXCEEDED429Out of magic-link, recovery, or API-key quota.

Providers

CodeStatusMeaning
AUTH/PROVIDER_FAILED502Upstream IdP returned a non-2xx (Google rejected the code, Apple JWKS unreachable, etc.).
AUTH/OAUTH_REUSE_DETECTED401Refresh-token reuse - RFC 6749 section 10.4 - entire family revoked.
AUTH/OAUTH_STATE_MISMATCH401Signed state value didn't verify or was too old.
AUTH/OAUTH_NONCE_REPLAY401OIDC nonce claim already seen.

CSRF / DPoP / JWT

CodeStatusMeaning
AUTH/CSRF403CSRF guard failed (cookie/header mismatch or missing).
AUTH/DPOP_INVALID401DPoP proof rejected: bad signature, wrong jti, replay, or thumbprint mismatch.
AUTH/JWT_INVALID401JWT failed signature, exp, nbf, aud, or iss check.
AUTH/JWT_KEY_UNKNOWN401JWT kid does not match any verify key (post-rotation drift).

Sign-up / recovery

CodeStatusMeaning
AUTH/SIGNUP_INCOMPLETE409Sign-up state machine is awaiting another step.
AUTH/SIGNUP_TOKEN_INVALID401Sign-up token wrong, expired, or already consumed.
AUTH/RECOVERY_TOKEN_INVALID401Password-reset token wrong or revoked.
AUTH/RECOVERY_TOKEN_EXPIRED401Reset token past its TTL.
AUTH/RECOVERY_REQUIRES_MFA403Recovery flow needs MFA before allowing reset (high-assurance accounts).

Identities

CodeStatusMeaning
AUTH/STALE_WRITE409Optimistic-concurrency check failed. Re-read and retry.
AUTH/GRACE_EXPIRED410Soft-delete grace period expired; identity is gone.
AUTH/EMAIL_TAKEN409Email collides with another identity.
AUTH/EMAIL_CHANGE_PENDING409Already a pending email-change for this identity.

Impersonation

CodeStatusMeaning
AUTH/IMPERSONATE_FORBIDDEN403Impersonator lacks the required role/scope.
AUTH/IMPERSONATE_REQUIRES_IAM500Impersonation guard requires an iam check but auth.iam is unset.
AUTH/IMPERSONATE_EXPIRED401Impersonation session is past its absolute TTL.

API keys / M2M

CodeStatusMeaning
AUTH/APIKEY_INVALID401API key not found or shape mismatch.
AUTH/APIKEY_REVOKED401Key found but revoked.
AUTH/APIKEY_SCOPE_INSUFFICIENT403Key valid but missing required scopes.

Runtime modes

CodeStatusMeaning
AUTH/MAINTENANCE503operations.enterMaintenance() is active.
AUTH/READONLY_MODE503operations.enterReadonly() is active; writes blocked.

Boot / configuration

CodeStatusMeaning
AUTH/MISCONFIGURED500Raised by AuthEngine.strict() when a production gate fails (AuthNoopLimiter, IamMemoryAdapter, etc.).

Wire-safe serialisation

const err = new AuthError('AUTH/INVALID_CREDENTIALS', {
  email: 'ada@example.com',
  password: 'plaintext!',       // <- will be stripped
  attemptCount: 3,
})

err.toJSON()
// -> { code: 'AUTH/INVALID_CREDENTIALS', status: 401, meta: { email: 'ada@example.com', attemptCount: 3 } }

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:

const handle = (err: AuthError) => {
  switch (err.code) {
    case 'AUTH/INVALID_CREDENTIALS':
      return { title: 'Invalid email or password', tone: 'error' as const }
    case 'AUTH/MFA_REQUIRED':
      return { title: 'Enter your authenticator code', tone: 'info' as const, 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' as const }
    case 'AUTH/EMAIL_NOT_VERIFIED':
      return { title: 'Please verify your email first', tone: 'info' as const, action: 'resend-verify' }
    case 'AUTH/EMAIL_TAKEN':
      return { title: 'That email is already in use', tone: 'error' as const }
    default:
      return { title: 'Authentication failed', tone: 'error' as const }
  }
}