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.
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.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. |
| rate_limited | A general request or hosted/OAuth limit was exceeded. | Wait for Retry-After, add jitter, then retry. |
| 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 · 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. |
| 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 exhausted its concurrency capacity. | Retry with bounded exponential backoff and jitter. |
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. |
| 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 (including prompt and max_age) — 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
A wrong proof on POST /api/v1/auth/session/reverify (step-up, see Sessions & tokens) or POST /api/v1/auth/password/change returns a plain 401 Unauthorized with no code field — the legacy, uncoded shape (only status, title, and detail), not the invalid_credentials-coded shape login uses. Branch on the 401 status; don't look for a code here.
429 with 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.