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.
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.
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.
{
"status": 400,
"title": "Bad Request",
"code": "organization_type_not_resolved",
"detail": "The user's organization type does not grant this application."
}| Field | Always present | Use it for |
|---|---|---|
| status | Yes | The HTTP status, repeated in the body. |
| title | Yes | A short human label — display, don't parse. |
| code | No | A stable machine string for branching in code. |
| detail | No | A specific, loggable description of this instance. |
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.
| Code | Meaning | Caller action |
|---|---|---|
| invalid_credentials | The 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_method | The password was verified, but no enabled, enrolled verification method is available. | Contact your organisation’s support. No session is issued. |
| user_passkey_unavailable | The 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_unavailable | The 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_limited | A general request or hosted/OAuth limit was exceeded. | Wait for Retry-After, add jitter, then retry. |
| remember_device_unavailable | 400: 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_invalid | 401 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_mismatch | 403: a live same-tenant session belongs to another application. | Use the calling application's own session; no other application is identified. |
| flow_superseded | 409: the authentication flow is unknown, expired, consumed or superseded. | Restart the flow. Invalid sessions on reverify use session_invalid instead. |
| stronger_proof_unavailable_here | 403: 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_required | 403 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_locked | The 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_found | A 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_expired | Signed 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_missing | The 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_unreadable | A 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_resolved | The 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_exhausted | An 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_limited | The enrolment token exceeded its per-minute budget. | Back off before redeeming it again. |
| installation_key_duplicate | The 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_reached | The tenant reached its active installation capacity. | Retire an installation or raise capacity before retrying. |
| enrolment_app_invalid · enrolment_workspace_invalid | Token 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_claimed | An 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_invalid | An 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_limit | A 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_changed | The 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_overloaded | The 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. |
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
| Status | Meaning & usual cause |
|---|---|
| 400 | Malformed request — a missing or invalid body field. |
| 401 | Bad tenant identity — wrong X-Tenant-Id, or the Bearer secret/token doesn't match this tenant. |
| 403 | Authenticated but not allowed — the user's role ∩ org-type ∩ package doesn't grant this app, or the token's scope is insufficient. |
| 404 | The resource doesn't exist in your tenant. |
| 409 | Conflict — e.g. a uniqueness constraint on create. |
| 422 | The request was well-formed but semantically invalid. |
| 429 | Rate limited — back off and retry after the Retry-After delay. |
| 503 | Service 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:
| Error | What it usually means & the fix |
|---|---|
| invalid_grant | On /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 ( 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:
Check in that order: re-copy the |
| invalid_request | A 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_token | A 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.
| Plane | Credentials rejected | Temporarily locked |
|---|---|---|
| Direct / BFF | 401, code invalid_credentials | 429, code login_temporarily_locked, plus Retry-After |
| Hosted / OIDC | 400 OAuth invalid_grant, error_code: invalid_credentials | 429 OAuth temporarily_unavailable, error_code: login_temporarily_locked, plus Retry-After |
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
429with errortemporarily_unavailableand aRetry-After. Itserror_codeisrate_limited. Platform default: 300 token requests per client per 60 seconds.
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.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." }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 oncode— treattitle/detailas display/log text. - Retry only
429/503(and network errors), always respectingRetry-Afterwith jitter. - On
401, re-check the tenant id and secret; on403, check role ∩ org-type ∩ package and token scope. - Stuck on a specific symptom? The onboarding troubleshooting table maps common failures to causes.