Skip to content
Authreads Docs

Errors & rate limits

Every error uses one predictable shape, and every rate limit tells you when to try again. Handle these two contracts and your integration degrades gracefully instead of guessing.

API sign-in v2 — password, proof, setup, exchange

Authenticate every call from your backend with the app login credential./api/v1/auth/login/preflight serves only v2; the optional Authreads-Login-Flow header accepts only 2. After password verification, follow next_method and alternatives: email code, authenticator, recovery code or a passkey on a verified application domain. Replace your stored flow_token after every transition. The original ten-minute expiry never extends; Back cancels the flow. Keep secrets and capabilities out of browser JavaScript, URLs and logs.

Backend-owned authenticator journey
POST /api/v1/auth/login/preflight
Authreads-Login-Flow: 2
{"email":"user@example.com","password":"…","min_acr":"urn:authreads:acr:mfa"}

POST /api/v1/auth/login/flow/verify-totp
{"flow_token":"<latest capability>","code":"<authenticator code>"}

# Only after a response containing grant:
POST /api/v1/auth/session/exchange
{"grant":"<one-time grant>"}

An unfinished response omits grant. Handle password_change_requiredwith /api/v1/auth/password/change-expired and {flow_token,new_password}, then restart sign-in. For strong_method.state Ask or Required, use/login/flow/enrol/totp/start and /confirm, or the passkey enrollment begin/finish endpoints. Only unexpired Ask permits /login/flow/remind-later. Enrollment authority is the flow; no account session is issued before setup. Setup uses the same account-wide strongest-factor rule as account security, including passkeys on another RP. Select an existing authenticator or recovery code before setup when that passkey cannot be used here. Preflight prefers an available recovery code before sending an email that cannot authorize setup; always follow the returned method. A final consumed recovery code remains repair proof; it does not become MFA evidence.

For passkeys, send the exact registered Authreads-Passkey-Origin on preflight, then call /login/flow/passkey/begin with the latest flow and origin. Forward the returned challenge options to the browser or native client and return the signed credential with its ceremony ID to /finish. Android certificate origins and Apple associated domains require verified app-domain registration; an arbitrary display domain is insufficient.

flow_superseded (409) requires a new flow; retries never issue another grant. Reverify returns 401 session_invalid for unknown, other-tenant, revoked or expired sessions. Clear the application session and require sign-in. A live same-tenant session for another app returns 403 session_app_mismatch. Respect Retry-After on 429. login_proof_rejected andlogin_method_unavailable need a server-directed proof or method change.strong_method_setup_required blocks completion until setup.fresh_authentication_required requires a fresh strongest-method sign-in; additional methods belong in authenticated account security.stronger_proof_unavailable_here means no usable repair proof is available: manage methods where the existing passkey works or use the organisation’s audited reset process.recovery_proof_required (403 on setup) means only a recovery code can authorize setup on this site: restart sign-in and choose recovery_code, or manage methods where the passkey works. A response carrying recovery_codes_exhausted: true (a sign-in with the last recovery code, or a setup it authorised) left the account with no unspent recovery codes; prompt the user to generate new ones. A malformed remembered-browser value returns 400trusted_device_invalid: discard the stored value and retry without it.app_passkey_domain_unverified and app_login_unavailable need an administrator repair. Retired endpoints return endpoint_removed with HTTP 410.

Completed v2 grants and valid end-user session responses expose acr,auth_time and cumulative amr. Contexts are orderedsingle, pwd, 2fa, mfa,phr, each prefixed with urn:authreads:acr:. Email and recovery with password reach 2fa; authenticator with password reaches mfa; a user-verified passkey reaches phr. Trusted-browser skips and enrollment bootstrap stay pwd. The proof supporting the strongest context owns auth_time; later password checks cannot make an older strong proof appear fresh. Operator tokens omit this end-user context. These values do not assert NIST AAL certification.

Request an optional min_acr on v2 preflight to restrict the proof methods. An existing password-proven app session can instead POST/api/v1/auth/login/flow/reverify with{"session_id":"<session bearer>","min_acr":"urn:authreads:acr:mfa"}. Follow the same rotating proof flow. Completion returns session_reverified:trueand fresh context without a grant; retain the original session and expiry. Passkeys require the exact verified origin at admission and ceremony calls.unmet_authentication_requirements means no configured method meets the floor; direct the user to account security setup. Trusted-device skips cannot satisfy it.

remember_device_unavailable is a 400 refusal before proof consumption; clear the option and retry with remember_device=false. Hosted OAuth publishes the same value in error_code. API and hosted offers, acceptance and token creation share one rule; reverify, Required rollout, recovery and requests above password strength cannot mint browser trust. Refresh the offer after method changes and resend.

On an actionable verification step, trusted_device_available tells you whether to offer remember-device; trusted_device_window_days uses effective policy. These fields are omitted during setup, password change, reverify and terminal responses. Test amr membership, never equality. First strong-factor enrolment returns recovery_codes once. Follow Let users manage 2FA from your app for authenticated account changes and lost-response recovery.

For a shared browser, keep a bounded, newest-first device_tokens list in backend-owned storage or an HttpOnly cookie. Send it on preflight and again with a completed verification. After completion, replace the stored list with the returneddevice_tokens. Keep the old material through a code challenge so a verified re-trust can rotate the same browser record. The platform permits at most ten entries, with a default storage cap of five. Never send both input forms; the singulardevice_token remains supported for existing clients. Keep all device credentials out of browser JSON and logs. Each entry is checked only for the user proving their password, tenant, pool, browser fingerprint and current strength requirement.

Tenant administrators can review the latest invitation, sign-in code and confirmation in Admin’s Email delivery section; platform operators have the equivalent Console view. Status is limited to the configured retention window and current address generation. Delivered means the receiving mail server accepted the message, not that it was read. These are private management views, not additional public sign-in endpoints. A current-address permanent bounce or complaint blocks invitation resends withinvite_delivery_blocked. A committed provisioning result instead carriesinvite.reason: delivery_blocked; preserve the successful account change and show its delivery warning. Correct the address or investigate the complaint before retrying.

Required is checked again before grant issuance, including after policy changes and remembered browser admission. Enrollment preserves the original AMR; a password-only setup does not become MFA. Recovery cannot create browser trust. Preflight always serves v2; explicit unsupported versions are refused. Retired v1 verification endpoints return 410.

The error shape

Failures return application/problem+json (RFC 7807). Always branch on the HTTP status first; use the stable code for programmatic handling and detail for logs — never parse title.

application/problem+json
{
  "status": 400,
  "title": "Bad Request",
  "code": "organization_type_not_resolved",
  "detail": "The user's organization type does not grant this application."
}
FieldAlways presentUse it for
statusYesThe HTTP status, repeated in the body.
titleYesA short human label — display, don't parse.
codeNoA stable machine string for branching in code.
detailNoA specific, loggable description of this instance.
Some errors add domain-specific fields. Organization-type resolution failures, for example, include requested and available so you can show the caller which org-types would have worked.

Spent sign-in codes

After password proof, an exhausted code-attempt budget returns 429 withrate_limited and Retry-After. A rejected proof with attempts remaining returns 401 login_proof_rejected. After the delay, restart sign-in or request a replacement code through the supported flow action; do not automatically retry the exhausted code. This distinction does not expose account existence before password proof.

Stable code vocabulary

This is the authoritative list of Authreads machine codes. A code is published only when it changes a caller's recovery action; errors without one are handled by HTTP or OAuth status. Published values are additive and will not be renamed or reused.

CodeMeaningCaller action
invalid_credentialsThe login proof was rejected; account existence is deliberately undisclosed.Do not retry the same proof. Let the user retry or recover credentials.
no_sign_in_methodThe password was verified, but no enabled, enrolled verification method is available.Contact your organisation’s support. No session is issued.
user_passkey_unavailableThe passkey operation is disabled by the resolved organisation policy; this says nothing about an account.On hosted login, continue with password. For account changes, contact your organisation. Do not retry the same challenge.
user_authentication_method_unavailableThe selected verification method was removed, disabled, or locked during authentication.Choose another available method or use the organisation’s recovery or unlock process. Do not keep retrying the unavailable method.
rate_limitedA general request or hosted/OAuth limit was exceeded.Wait for Retry-After, add jitter, then retry.
remember_device_unavailable400: the server cannot offer browser trust for this method, requested strength, policy, reverify transaction or fingerprint. Hosted OAuth uses the same error_code extension.Hide or clear the trust option and retry the unused proof with remember_device=false; refresh offers after method changes or resend.
session_invalid401 on reverify and account-factor routes: the session is unknown, expired, revoked or outside this tenant.Clear the application session and require sign-in. Never retry the old session token.
session_app_mismatch403: a live same-tenant session belongs to another application.Use the calling application's own session; no other application is identified.
flow_superseded409: the authentication flow is unknown, expired, consumed or superseded.Restart the flow. Invalid sessions on reverify use session_invalid instead.
stronger_proof_unavailable_here403: the account's strongest factor cannot be proven on this surface and no usable authenticator or recovery proof is available.Manage methods where the existing passkey works or use the organisation's audited reset process. Password/email cannot authorize weaker setup.
recovery_proof_required403 on sign-in setup: the account's strongest factor cannot be proven on this surface, so only a recovery code can authorize setup here, and the flow was proven another way (for example an email code).Restart sign-in and have the user choose a recovery code, or manage methods on the site where the existing passkey works. Do not restart with the same method; it cannot succeed.
login_temporarily_lockedThe account/network login brake or enforced tenant lockout fired.Wait for Retry-After before another sign-in attempt.
revoked · expired · user_disabled · tenant_disabled · not_foundA direct session is no longer usable, with the exact terminal reason.Clear the application session and require sign-in; disabled identities need administrator action.
session_activity_invalid · session_idle_timeout · session_activity_expiredSigned browser-activity state is invalid, idle, or past its absolute lifetime.End the session or restart authentication; never synthesize activity state.
app_unknown · user_inactive · app_disabled · organization_unknown · organization_inactive · no_membership · membership_inactive · no_role · role_denied · org_type_denied · package_unassigned · package_inactive · entitlement_missingThe access evaluator denied token issuance at the named policy layer.Correct tenant access configuration; do not retry unchanged input.
invalid_request_body · malformed_json · unsupported_media_type · request_body_unreadableA JSON request body was rejected before the handler ran: wrong shape or an unknown field (422), invalid JSON (400), a missing JSON content type (415), or an unreadable/oversized body.Fix the request body against the published schema; do not retry unchanged input.
organization_type_not_resolvedThe requested organization-type key/environment pair is not configured.Use requested/available/available_total to choose or configure an exact pair.
enrolment_token_unknown · enrolment_token_revoked · enrolment_token_expired · enrolment_token_not_yet_valid · enrolment_token_exhaustedAn installation enrolment token cannot be used for the named lifecycle reason.Issue/use an appropriate token; only not-yet-valid becomes usable without replacement.
enrolment_token_rate_limitedThe enrolment token exceeded its per-minute budget.Back off before redeeming it again.
installation_key_duplicateThe tenant already has an installation with the submitted public key.Treat the redemption as a retry conflict; do not generate another ticket use implicitly.
installation_limit_reachedThe tenant reached its active installation capacity.Retire an installation or raise capacity before retrying.
enrolment_app_invalid · enrolment_workspace_invalidToken issuance named an app or workspace outside the target tenant.Correct the exact reference named by the code; never retry the unchanged identifier.
identity_source_issuer_duplicate · external_principal_duplicate · external_principal_already_claimedAn issuer, source-scoped subject, or live principal claim conflicts with retained identity state.Resolve the exact duplicate/claim condition; do not infer identity ownership from human-readable text.
identity_source_tenant_invalid · external_principal_source_invalid · identity_binding_principal_invalid · identity_binding_user_invalidAn external-identity write named a tenant-scoped reference that is not valid in the transaction tenant.Correct the exact reference named by the code.
installation_limit_above_maximum · installation_maximum_below_limitA capacity update would cross the operator-approved maximum or place the maximum below the active configured limit.Use the operator exception endpoint first, or lower the tenant limit before lowering its maximum.
installation_campaign_changedThe active membership of a deployment campaign changed after its blast radius was displayed.Refresh the campaign count, review the new impact, and require a fresh human confirmation; never retry unchanged.
service_overloadedThe public or private API plane stayed at its concurrency cap for the whole short admission wait (500 ms by default). The 503 carries Retry-After.Wait at least Retry-After, then retry with bounded exponential backoff and random jitter; never retry in a tight loop.
OAuth's standard error remains the protocol-level category. Hosted login adds the same Authreads value as error_code; direct/BFF problem details usecode. The three login values are one typed server contract and are also emitted into OpenAPI as an enum, so adding one requires an explicit code and contract-test update.

Account-security errors

BFF account-security routes deliberately normalize an unknown, deleted, cross-tenant, or wrong-user-pool user/app pair to the same 404. This prevents the endpoint from becoming an identity-enumeration oracle. A missing account-security:read or account-security:manage scope returns 403.

“Revoke others” returns 404 when current_session_id is not a live account-session handle for that same tenant, user, and app. Repeating a completed individual revocation succeeds with { "revoked": 0 }; it is not an error and does not create another audit record.

Status codes you'll see

StatusMeaning & usual cause
400Malformed request — a missing or invalid body field.
401Bad tenant identity — wrong X-Tenant-Id, or the Bearer secret/token doesn't match this tenant.
403Authenticated but not allowed — the user's role ∩ org-type ∩ package doesn't grant this app, or the token's scope is insufficient.
404The resource doesn't exist in your tenant.
409Conflict — e.g. a uniqueness constraint on create.
422The request was well-formed but semantically invalid.
429Rate limited — back off and retry after the Retry-After delay.
503Service temporarily unavailable — retry only when the response explicitly permits it.

OAuth & token errors

The OAuth endpoints return standard OAuth error codes. These four cause the most confusion — here's what each really means:

ErrorWhat it usually means & the fix
invalid_grantOn /oauth/login/preflight, the description invalid credentials means the password-stage login was rejected without revealing whether the account exists. On token endpoints, the auth code may have been reused or expired (codes are single-use and short-lived), the redirect_uri didn't match at token exchange, the PKCE code_verifier was wrong, or a refresh token was expired/revoked. Don't retry a failed code exchange — it fails permanently; send the user through login again. For the DPoP-bound installation refresh grant, installation_recovery_action: "reenrol" means the installation was retired. Obtain a new enrolment ticket and register a new installation; retrying the old credential cannot restore it.
invalid_client

Client authentication failed. Confidential clients use exactly one of HTTP Basic (client_secret_basic) or form credentials (client_secret_post); presenting both is rejected. Public clients use PKCE with no secret.

On the client-credentials grant specifically (machine tokens), this one code covers three distinct causes, deliberately not distinguished — telling them apart would let a caller enumerate valid client ids:

  • the client_id doesn't exist;
  • the client_secret doesn't match that client_id;
  • the client, or its tenant, isn't active.

Check in that order: re-copy the client_id shown alongside the secret on the credential's reveal screen (both are shown together, once, specifically so you can confirm they're a matching pair before storing them separately); confirm the secret wasn't truncated or altered in transit into your secret manager; then confirm the credential and its tenant are both still active in the dashboard. A pair assembled from two different credential creations — id from one, secret from another — is structurally valid (right prefixes, right length) and fails exactly this way, with nothing to eyeball.

invalid_requestA required parameter is missing or duplicated — e.g. no code_challenge on authorize, or credentials sent two ways at once. On /oauth/authorize specifically, this is also what you get for any query parameter it doesn't recognize, or a prompt value other than none or login — see Known incompatibilities with common OIDC libraries if you're integrating a generic OIDC client.
invalid_tokenA token your API verified failed on signature, kid, iss, aud, or exp. Walk the checklist in Verify a token.

Login failures on direct and hosted flows

Both login planes preserve the same privacy boundary but use their native response format. A wrong password and an unknown email remain indistinguishable. A temporary lock is explicit because the caller needs a safe retry time.

PlaneCredentials rejectedTemporarily locked
Direct / BFF401, code invalid_credentials429, code login_temporarily_locked, plus Retry-After
Hosted / OIDC400 OAuth invalid_grant, error_code: invalid_credentials429 OAuth temporarily_unavailable, error_code: login_temporarily_locked, plus Retry-After
Tenant policy lockout and the always-on abuse brake intentionally share the locked response. They require the same recovery action, and identifying which threshold fired would disclose defensive configuration without helping the person sign in.

Password-change and re-verify failures

Reverify uses /api/v1/auth/login/flow/reverify and the selected method's typed proof errors. Factor changes may return fresh_authentication_required with min_acr, session_app_mismatch, last_strong_factor_required or backend_request_required. See the factor error catalogue.

endpoint_removed is HTTP 410 and includes a migration link. unsupported_login_flow is HTTP 400; omit the version header or send 2. trusted_device_invalid is HTTP 400 for a malformed remembered-browser value; discard it and retry without it. Password-change refusals still use their published status/code contract. Never automatically retry a human proof or secret-bearing mutation; respect Retry-After.

Rate limits

Authreads protects sensitive endpoints with independent brakes rather than one global quota. When you hit one, the response carries a Retry-After header (in seconds) telling you exactly when to try again:

  • Login brake — repeated failed logins are throttled per account and per network, so credential-stuffing slows to a crawl without locking a real user out permanently. Platform default: 5 failures per account in a 15-minute window trips a 15-minute lock.
  • Password-change & re-verify brakes — step-up and password-change flows have their own separate limits, isolated from the login brake above (see previous section). Platform default: 5 attempts per 15 minutes, keyed per tenant + user, for each of the two flows independently.
  • Token / OAuth brake — pre-auth OAuth requests are keyed on the client (or verified tenant), never the raw IP. Over-limit OAuth requests return 429 with error temporarily_unavailable and a Retry-After. Its error_code is rate_limited. Platform default: 300 token requests per client per 60 seconds.
Cache the machine token; don't mint one per call. At 300 requests/minute, a naive per-request mint exhausts the token brake under any real load. Reuse the access token until shortly before expires_in elapses — see Management API → Get a machine token. These are the platform's current configured values, not a contractual ceiling — they can change without notice. Always drive retry timing from Retry-After, never from a number hard-coded against this page.
429 Too Many Requests
HTTP/1.1 429 Too Many Requests
Retry-After: 42
Content-Type: application/problem+json

{ "status": 429, "title": "Too Many Requests",
  "code": "login_temporarily_locked",
  "detail": "Sign-in is temporarily locked." }
Honor Retry-After. It's the only rate-limit signal — there are no X-RateLimit-* headers. On a 429 or 503, wait the stated seconds (with jitter) before retrying; don't hammer.

A resilient client, in short

  • Branch on status, then on code — treat title/detail as display/log text.
  • Retry only 429/503 (and network errors), always respecting Retry-After with jitter.
  • On 401, re-check the tenant id and secret; on 403, check role ∩ org-type ∩ package and token scope.
  • Stuck on a specific symptom? The onboarding troubleshooting table maps common failures to causes.