Error codes
Every failure listed here is one a client can receive. Endpoint-by-endpoint request and response shapes live in the IAM API reference.
Response envelopes
Section titled “Response envelopes”The service does not ship one error shape. Five are reachable, and which one you get depends on the surface and on how far the request got before it failed.
| Envelope | Surface | Body |
|---|---|---|
| Code envelope | /auth/*, /superadmin/impersonate, /superadmin/registry/*, /internal/session/* | {"error":"<message>","code":"<slug>"} |
| Code envelope with details | /rest/*, /anon/rest/*, /schema/*, /admin/user/*, /admin/role/*, /superadmin/tenant/*, DELETE /cache | {"error":"<message>","code":"v2.<slug>","details":{…}} — details omitted when empty |
| Message only | /admin/data/* | {"error":"<message>"} — no code field at all |
| Rate-limit body | the eight rate-limited /auth/* routes | {"error":"rate_limited","profile":"<profile>","retry_after_seconds":N} — error holds the slug, and there is no code field |
| Plain text | any route where the credential or the tenant is rejected before a handler runs | Content-Type: text/plain with a bare message — Unauthorized, invalid tenant, config client not initialised |
Key on status and code together
Section titled “Key on status and code together”Codes are not unique. The same slug carries different statuses on different routes, so
(status, code) is the pair to switch on.
| Code | Status by surface |
|---|---|
no_tenant | 400 from /auth/* handlers · 401 from the credential middleware · 403 as v2.no_tenant on the entity surface |
unknown_realm | 400 on POST /auth/login · 401 on POST /auth/mfa/verify |
exchange_failed | 401 on the OAuth callback · 500 on POST /internal/session/exchange |
invalid_session | 401 for a WebAuthn ceremony session · 401 for a managed session |
rotate_failed | 500 for refresh-token rotation · 500 for managed-session rotation |
list_failed | 500 on GET /internal/session/list · 500 on GET /superadmin/registry/tenants |
Request shape and tenancy
Section titled “Request shape and tenancy”| Code | Status | Meaning | Cause |
|---|---|---|---|
bad_request | 400 | Body could not be decoded, or a required top-level field was absent | Malformed JSON, empty body, or a body over the 1 MiB request cap |
missing_fields | 400 | Body decoded, a required field was empty | The message names the fields — identity and password are required, mfa_token and code are required, email, code, and new_password are required, and so on |
missing_id | 400 | The :id path segment was empty | DELETE /auth/apikey/:id called with no id |
no_tenant | 400 | No resolvable tenant on the request | The four CPET headers were absent or incomplete. Message on login and refresh: tenant identity missing from request — set X-Tenant or equivalent headers |
no_tenant | 401 | A credential was presented with no tenant context | Bearer or ApiKey sent without CPET headers, so no per-tenant keyring can verify it |
engine_unavailable | 500 | The tenant’s datastore could not be reached | Database down, wrong DSN, an unregistered product segment, or a tenant that was never provisioned. Message is always could not reach datastore |
(plain text) invalid tenant | 400 | The CPET headers match no configured tenant | The middleware tries the full four-segment key, then the bare X-Tenant value, then refuses |
(plain text) config client not initialised | 400 | The tenant middleware could not resolve the config client | Deployment wiring problem, not a client error |
See Tenancy for how the four headers resolve.
Authentication
Section titled “Authentication”| Code | Status | Meaning | Cause |
|---|---|---|---|
invalid_credentials | 401 | Identity or password was wrong | Deliberately never says which. Wrong password, unknown identity, inactive identity and an identity row with no password hash all return this, and the miss path spends an equal argon2 budget so timing does not leak existence |
bad_identity_type | 400 | identity_type names a column the realm’s entity does not declare as a login identifier | The message lists the declared identifiers |
unknown_realm | 400 | The named realm does not exist in the tenant’s composed config | Typo in realm, or the product’s realm config was never delivered to this CPET |
provider_not_allowed | 403 | The realm exists but does not list password among its providers | Message: realm <name> does not allow password login. The realm provider gate applies to password login only |
hook_denied | 403 | An auth-flow hook aborted the request | The hook’s own error text is returned verbatim, so this message is not a stable string |
login_abuse | 429 | A check_login_abuse hook denied the attempt | Distinct from the built-in rate limiter. Only fires when a product registers such a hook |
user_query_failed | 500 | The identity lookup query itself errored | Engine or SQL failure, or a missing identity table. Message: lookup failed |
mfa_lookup_failed | 500 | Could not determine whether the user has MFA enabled | Engine failure reading the factor table |
sign_failed | 500 | The per-tenant signing key could not sign the claim set | Bad bring-your-own key material, or an unreachable key store |
issue_failed | 500 | The refresh token could not be persisted | Write failure |
update_failed | 500 | A new password could not be written | Write failure on POST /auth/password/reset/confirm |
code_failed | 500 | Secure random code generation failed | Entropy source failure on reset-request and magic-request |
invalid_reset_token | 401 | The password-reset code is unknown, expired, or already used | One code and one message for all three — no oracle |
already_bootstrapped | 403 | POST /auth/bootstrap is closed | Three causes, one message (bootstrap not available): no bootstrap token configured, a wrong token, or a superadmin already exists |
superadmin_not_deployed | 503 | The control-plane scope is not deployed for this product and environment | No reserved superadmin tenant, or its schema was never bootstrapped |
policy_unavailable | 503 | The tenant’s session policy could not be resolved | The service refuses rather than guessing stateless-versus-managed, which would silently downgrade a tenant |
persist_failed | 500 | A required row could not be written | Messages name the row: could not create superadmin, could not persist reset request, could not persist magic link |
Full flow detail: Password login and Realms.
Multi-factor
Section titled “Multi-factor”| Code | Status | Meaning | Cause |
|---|---|---|---|
invalid_mfa_token | 401 | The mfa_token is unusable | Four messages behind one code: invalid MFA token (twice), MFA token expired, wrong token type (a full access token sent where an MFA-pending one is required) |
invalid_code | 401 | The TOTP code did not validate | Wrong code, or client clock skew beyond the step. On disable the message is a valid TOTP code is required to disable MFA — a hijacked session alone cannot strip MFA |
code_reused | 401 | The code was already consumed within its step | Message: this code was already used; wait for the next one |
mfa_not_enabled | 401 | Verification attempted for a user with no enabled factor | Stale client flow, or the factor was disabled between login and verify |
unknown_realm | 401 | The realm on the MFA-pending token no longer exists | Realm config changed between login and verification |
already_enabled | 409 | TOTP is already enrolled | Message on setup: TOTP is already enabled; disable first to re-enrol |
no_pending_setup | 400 | No enrolment is in progress | enable called before setup, or the pending setup expired |
lookup_failed | 500 | Could not read existing MFA state | Message: could not check existing MFA setup |
user_lookup_failed | 500 | Could not re-read the user row to build the post-MFA token | Engine failure |
persist_failed | 500 | The pending secret or the enabled factor could not be written | Messages: could not persist MFA setup, could not enable TOTP |
See Multi-factor authentication.
Magic link
Section titled “Magic link”| Code | Status | Meaning | Cause |
|---|---|---|---|
invalid_magic | 401 | The code is unknown, expired, or already consumed | Three call sites, one message — a second click on a one-shot link is indistinguishable from a wrong code |
delete_failed | 500 | The one-shot link could not be consumed after a successful verify | A hard failure rather than a warning, because the link would otherwise stay replayable |
persist_failed | 500 | The link row could not be written | Message: could not persist magic link |
Passkeys
Section titled “Passkeys”Every /auth/webauthn/* route answers 503 webauthn_disabled with the message
WebAuthn not configured. The gate is the first statement in all four handlers, and the
deployable binary never supplies a relying-party configuration. There is no operator-facing
setting that changes this.
| Code | Status | Meaning |
|---|---|---|
webauthn_disabled | 503 | The only response the four passkey routes produce |
The rest of the ceremony’s codes exist in the implementation but sit behind that gate. Background: Magic link, WebAuthn and passkeys.
Every /auth/oauth/:provider/* request answers 404 unknown_provider with the message
OAuth provider not configured. The provider lookup runs before anything else, and the
deployable binary registers no provider. Declaring type: oauth in a provider declaration
wires nothing — nothing reads that field at request time.
| Code | Status | Meaning |
|---|---|---|
unknown_provider | 404 | The only response the OAuth routes produce |
Background: OAuth and federated identity.
Tokens, refresh and API keys
Section titled “Tokens, refresh and API keys”| Code | Status | Meaning | Cause |
|---|---|---|---|
auth_required | 401 | The route needs an authenticated principal and none was attached | No Authorization header, or a scheme other than Bearer / ApiKey — both are prefix-matched, case-sensitive, one space |
invalid_token | 401 | The token failed verification, was the wrong kind, or its identity is gone | Four messages: token validation failed, MFA-pending tokens cannot be used for protected requests, invalid refresh token, identity no longer valid |
token_expired | 401 | The token is outside its exp/nbf window | A token carrying no exp is rejected here too — absence is treated as forgery |
tenant_mismatch | 401 | The token’s tenant claim names a different CPET than the request headers | Cross-tenant replay. There is no superadmin exception |
token_reuse | 401 | An already-rotated refresh token was replayed | The whole rotation family descended from that login is revoked; the user must log in again |
managed_session | 403 | The tenant runs managed sessions, where refresh tokens do not exist | Exchange the opaque session reference instead |
invalid_api_key | 401 | An ApiKey credential failed shape, secret or expiry validation | One opaque code for every cause. A missing or non-time expiry value counts as expired |
apikey_required | 401 | POST /auth/token was called without Authorization: ApiKey <key> | A Bearer does not satisfy it |
apikey_invalid | 401 | The presented service key failed validation | Never distinguishes unknown key from bad secret from expired key |
not_a_service_key | 403 | The key is valid but carries no scope in its capabilities | An ordinary user key cannot be exchanged for a service token |
scope_exceeds_authority | 403 | Requested key capabilities exceed the issuer’s own | A CPET scope the issuer cannot confer, a role it does not hold, or a superadmin marker from a non-superadmin issuer |
generate_failed | 500 | Key material generation failed | Entropy source failure |
rotate_failed | 500 | Refresh-token rotation failed | Write failure retiring the old token or issuing the new one |
lookup_failed | 500 | The refresh-token read failed | Message: refresh-token lookup failed |
mint_failed | 500 | The service token could not be signed | Same causes as sign_failed |
jwks_failed | 500 | The tenant’s key set could not be assembled | The tenant’s signing-key table is unreachable, or key material is corrupt |
See Tokens and API keys and service tokens. Errors a relying service sees when verifying a token are in Verifying tokens in another service.
Managed sessions
Section titled “Managed sessions”| Code | Status | Meaning | Cause |
|---|---|---|---|
invalid_session | 401 | The session reference is invalid, expired, or revoked | One status for all three. Message: session invalid or expired |
unknown_session | 404 | No session with that control-plane id | POST /internal/session/rotate returns 404 for the condition /exchange returns 401 for |
session_limit | 409 | The tenant’s concurrent-session limit is reached and its policy is reject | Set the policy to evict the oldest session instead |
session_service_unavailable | 500 | The tenant resolves to managed sessions but no session service is wired into the process | A deployment fault. The service refuses rather than silently downgrading the tenant to a stateless token |
session_failed | 500 | A session row could not be created | Messages: could not create session, could not open an impersonation session |
exchange_failed | 500 | The exchange failed for a reason other than an invalid session | Store or mint failure. Message: could not exchange session |
revoke_failed | 500 | The session could not be revoked | Store failure |
rotate_failed | 500 | Session rotation failed | Store failure |
list_failed | 500 | Session enumeration failed | Store failure |
See Sessions.
Authorization
Section titled “Authorization”| Code | Status | Meaning | Cause |
|---|---|---|---|
v2.requires_admin | 403 | The caller holds neither admin nor superadmin | Role matching is case-insensitive; superadmin supersedes admin |
v2.requires_superadmin | 403 | The caller lacks superadmin | Strict — admin does not suffice. The marker is derived from the reserved realm at mint time, so a tenant identity cannot forge it |
unauthorized | 401 | No verified identity on an impersonation request | Called without a valid control-plane bearer |
not_an_operator | 403 | The caller’s token is not the control plane’s | Message: impersonation is a control-plane operation |
role_required | 403 | The operator holds neither impersonate-readonly nor impersonate | Neither role is implied by any other. A read-write request requires the strictly more privileged one |
not_granted | 403 | The operator has no grant over the target tenant | Returned identically when the tenant does not exist, and when the target is outside the operator’s own plane, so the endpoint is not a tenant-existence oracle. Message: you do not manage that tenant |
impersonation_not_allowed | 403 | The target user is not opted in to impersonation | Per-user consent. An operator cannot write that field, so cannot self-authorise |
reason_required | 400 | The mandatory reason field was empty | Non-optional: it is the only record of why an operator entered an account |
bad_tenant | 400 | The target tenant is not a full four-segment key | Message: tenant must be a full customer:product:env:tenant key |
no_such_user | 404 | No active user with that id in the target tenant | Wrong id, or a soft-deleted or inactive user |
tenant_unavailable | 503 | The target tenant’s datastore could not be reached | Target tenant database down or unprovisioned |
lookup_failed | 500 | The target user could not be read | Message: could not read the target user |
See Authorization model, Superadmin and Impersonation.
Administration
Section titled “Administration”Tenant registry and provisioning, under /superadmin/registry/*.
| Code | Status | Meaning | Cause |
|---|---|---|---|
reserved_or_invalid_name | 400 | The tenant slug is empty, is not a valid four-segment key, or uses a reserved scope name | superadmin and shared are reserved in every segment |
superadmin_not_deployed | 503 | The control plane is not deployed for this product and environment | The registry lives in the plane; without it there is nothing to write to |
create_failed | 500 | The registry row could not be written | Message: could not register tenant |
list_failed | 500 | The registry query failed | Message: could not list tenants |
provision_failed | 500 | Provisioning failed for an unclassified reason | The underlying error text is returned verbatim. Errors whose text names a missing registration, an invalid CPET, a reserved scope, or a required field are reclassified to 400 bad_request instead |
The tenant-scoped and cross-tenant data plane, /admin/data/* and
/superadmin/tenant/:tenant/*, uses the message-only envelope: {"error":"<message>"}, with
no code field at all. Status carries the whole classification — 401 for a missing tenant
identity, 403 for a missing tenant key in context, 400 for a missing path or query
parameter, 404 for an unknown plan, 500 for a failed ledger read or lock operation.
The role gates in front of those routes still answer with the code envelope
(v2.requires_admin, v2.requires_superadmin).
Cross-tenant route errors:
| Code | Status | Meaning |
|---|---|---|
v2.missing_tenant | 400 | The :tenant path parameter was empty |
v2.invalid_tenant | 400 | The :tenant slot could not be composed into a valid key against the caller’s own customer, product and environment |
v2.list_tenants_failed | 500 | Tenant inventory enumeration failed |
v2.invalidate_failed | 500 | Tenant cache invalidation failed |
Rate limits
Section titled “Rate limits”Eight routes carry a built-in per-tenant, per-caller-IP token bucket: login, MFA verify,
password reset request and confirm, magic request and verify, API-key issue, and service-token
exchange. A denial is 429 with Retry-After, X-RateLimit-Limit and X-RateLimit-Remaining
headers, and this body:
{"error":"rate_limited","profile":"auth_login","retry_after_seconds":2}| Profile | Routes | Capacity | Refill |
|---|---|---|---|
auth_login | POST /auth/login | 30 | 0.5/s |
auth_mfa | POST /auth/mfa/verify | 30 | 0.5/s |
auth_reset | POST /auth/password/reset/request, /confirm | 15 | 0.25/s |
auth_magic | POST /auth/magic/request, /verify | 15 | 0.25/s |
auth_token | POST /auth/token | 60 | 1/s |
auth_apikey | POST /auth/apikey | 15 | 0.25/s |
The entity surface has no limiter installed at all — its 429 path is unreachable in this
service.
Entity and schema surface
Section titled “Entity and schema surface”/rest/*, /anon/rest/*, /admin/user/*, /admin/role/* and /schema/* come from the
shared entity-surface library and prefix every code with v2..
| Code | Status | Meaning |
|---|---|---|
v2.no_tenant | 403 | No tenant key in the request context — note the status differs from the auth surface |
v2.bad_path | 400 | The :entity path segment was empty |
v2.entity_not_found | 404 | No entity by that name in the composed schema |
v2.bad_json | 400 | The body was not valid JSON |
v2.missing_id | 400 | An update without an id in the path must carry the primary key in the body |
v2.id_mismatch | 422 | The primary key in the body differs from the one in the path; the key is immutable |
v2.row_not_found | 404 | No row with that id |
v2.query_failed | 403 · 404 · 422 · 500 | A read failed or was refused — see the classification note below |
v2.create_failed | 403 · 404 · 422 · 500 | A create failed or was refused |
v2.update_failed | 403 · 404 · 422 · 500 | An update failed or was refused |
v2.delete_failed | 403 · 404 · 422 · 500 | A delete failed or was refused |
v2.restore_failed | 403 · 404 · 422 · 500 | A restore of a soft-deleted row failed or was refused |
v2.search_query_failed | 403 · 404 · 422 · 500 | A structured search failed or was refused |
v2.engine_unavailable | 500 | The per-tenant engine could not be resolved; the underlying error text is returned verbatim |
v2.search_body_parse_failed | 400 | The search body was not decodable |
v2.search_text_not_supported | 501 | Free-text search needs a search backend this service does not wire — send a structured filter |
v2.search_dsl_not_supported | 501 | The legacy filter-as-string form is not interpreted — send filter as a JSON object |
v2.empty_envelope | 400 | The bulk operations envelope was empty |
v2.body_read | 400 | The bulk request body could not be read |
v2.bulk_failed | 422 | The bulk transaction rolled back; per-operation results are in details.operations, each failed one tagged v2.op_failed |
v2.bad_expression | 400 | The ?expression= response-shaping expression failed to compile |
v2.expression_runtime | 500 | The expression compiled but failed at evaluation |
v2.missing_entity | 400 | The entity path segment on a schema or export route was empty |
v2.export_render_failed | 500 | A schema export (GraphQL SDL, OpenAPI, JSON Schema, Zod, CUE) could not be rendered |
v2.cache_clear_failed | 500 | DELETE /cache could not drop the tenant’s cached resources |
v2.file_storage_unconfigured | 501 | Multipart entity create needs a file-storage backend this service does not wire |
v2.file_downloader_unconfigured | 501 | File download needs a downloader this service does not wire |
v2.multipart_parse | 400 | The multipart body failed to parse |
v2.multipart_empty | 400 | The multipart form was empty |
v2.multipart_bad_payload | 400 | The JSON part of the multipart body was invalid |
v2.download_needs_id | 400 | ?download=true requires a row id in the path |
v2.missing_field | 400 | ?field= was absent on a download |
v2.field_not_found | 404 | ?field= names no field on the entity |
v2.field_not_file | 400 | ?field= names a field that is not a file |
v2.field_empty | 404 | The named field resolved to an empty value |
v2.field_unexpected_shape | 500 | The named field holds something other than a string reference |
v2.download_failed | 500 | The downloader errored |
What a caller never sees
Section titled “What a caller never sees”v2.authz_denied— emitted by an authorizer; the entity surface authorizes through entityaccess:rules instead, which deny with the codes above.v2.rate_limited— rate limiting is applied at the auth routes listed earlier, not on the entity surface.- Codes from the GraphQL, scripted-endpoint, action, state-flow, query-hash and bulk import/export surfaces. Those are library surfaces this service does not mount.