Errors
LumoAuth uses standard HTTP response codes. The error body shape depends on
which endpoint family you hit: three JSON contracts for the REST and OAuth
surfaces, plus the SCIM error schema on /scim2.0. There is no problem-details
(application/problem+json) format anywhere in the API. Match your parser to the family
below.
HTTP Status Codes
| Code | Meaning | Action |
|---|---|---|
200 OK | Request succeeded. | — |
201 Created | Resource was created. | — |
204 No Content | Succeeded with no response body (common for DELETE). | — |
400 Bad Request | Malformed request or invalid parameters. | Fix the request |
401 Unauthorized | Missing or invalid credentials/token. | Refresh token or re-authenticate |
403 Forbidden | Authenticated but not authorized. | Check scopes and permissions |
404 Not Found | Resource does not exist. | Check the endpoint URL and org ID |
409 Conflict | Resource already exists (e.g. duplicate email). | Read or update instead of create |
422 Unprocessable Entity | Validation errors on the submitted data. | See details field |
429 Too Many Requests | Rate limit exceeded. | Back off — check Retry-After header |
500 Internal Server Error | Unexpected server error. | Retry with exponential backoff |
503 Service Unavailable | Temporarily unavailable (maintenance, overload). | Retry later |
The three error-body contracts
| Contract | Shape | Which endpoints |
|---|---|---|
| 1. OAuth-style | { "error", "error_description" } | Protocol endpoints: OAuth 2.0 / OIDC (authorize, token, introspection), AAuth (/aauth/*), JIT (/jit/*), MCP token/challenge errors, dynamic client registration. This is the OAuth 2.0 error shape. |
| 2. Flat | { "error" } — sometimes just a code, sometimes with an HTTP-status echo | Some agent / MCP protocol responses (e.g. JIT surfaces flat { "error": "<code>" } such as invalid_token, request_not_found). Distinguish success from error by the HTTP status or the presence of error. |
| 3. Rich envelope | { "error", "message", "status", "details" } | The admin REST API and the validation layer (/admin/*, resource CRUD, form/field validation). details maps each invalid field to a message, or carries structured context such as rejected_keys or required_scopes. |
| SCIM | { "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], "status", "detail" } with Content-Type: application/scim+json | Everything under /orgs/{orgId}/api/v1/scim2.0/**, including authentication failures (401) and rate limiting (429). This is the SCIM 2.0 error shape. status is a string. |
Notes:
- The flat and OAuth-style contracts overlap: a flat
{ "error": "invalid_token" }and an OAuth{ "error": "invalid_token", "error_description": "…" }share theerrorcode — the difference is whethererror_descriptionis present. Readerrorfor the machine code; treaterror_description/messageas optional. - The rich envelope's
statusfield mirrors the HTTP status code; don't rely on it being present outside the admin/validation layer.
Examples
{
"error": "invalid_client",
"error_description": "Client authentication failed"
}
{
"error": "request_not_found"
}
{
"error": "validation_error",
"message": "Validation failed",
"status": 400,
"details": {
"email": "Invalid email format",
"password": "Must be at least 8 characters"
}
}
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"status": "401",
"detail": "Invalid or expired credentials"
}
Read error first — it's the one field present in every contract except SCIM (read detail there). Fall back to
error_description then message for a human-readable string, and read
details only for the rich validation envelope. The
error-handling example below does exactly this.
Common API Error Codes
error | Status | Description |
|---|---|---|
validation_error | 400 / 422 | One or more request fields failed validation. See details. |
invalid_request | 400 | Missing or malformed request parameter. |
invalid_client | 401 | OAuth client authentication failed. |
invalid_token | 401 | Access token is missing, expired, or invalid. |
unauthorized | 401 | No credentials provided. |
invalid_api_key | 401 | The lmk_ API key is missing, malformed, expired, revoked, for another organization, or its creator is no longer active. |
insufficient_user_authentication | 401 | The access token's auth_time is too old for this action (a step-up authentication challenge). Re-authenticate with the max_age from WWW-Authenticate. See MFA authentication. |
forbidden | 403 | Admin API: the credential is valid but not allowed for this request. See Admin API authentication and authorization errors. |
access_denied | 403 | Authenticated but insufficient privileges (OAuth surface). |
insufficient_scope | 403 | Token scope does not cover this resource (OAuth surface). |
feature_not_in_plan / plan_limit_reached | 403 | The organization's plan doesn't include this feature (for example ABAC or Zanzibar/ReBAC authoring, which needs the Fine-grained authorization add-on) or its limit is used up. The body names the feature or limit and an upgrade object with the plan or addon that unlocks it. See When a plan lacks a feature. |
access_policy_denied | 403 | An agent credential was blocked by a conditional access policy; reason carries the policy code. |
not_found | 404 | Resource does not exist. |
conflict | 409 | Resource already exists. |
rate_limit_exceeded | 429 | Too many requests. Retry after Retry-After seconds. |
rate_limited | 429 | The per-credential limit on the Admin API, authorization and /me APIs was reached. Retry after Retry-After seconds. See Rate limits. |
server_error | 500 | Unexpected internal error. |
temporarily_unavailable | 503 | Service temporarily unavailable. |
Admin API authentication and authorization errors
The Admin API (/orgs/{orgId}/api/v1/admin/**) uses the rich envelope. Two codes cover
almost every authentication problem; read message (and details when present) to tell
the causes apart.
401 unauthorized / 401 invalid_api_key / 401 invalid_token
No usable credential. WWW-Authenticate names the scheme that was expected
(ApiKey realm="Admin API" or Bearer realm="API"). Common causes: no header, an expired
or revoked key, an API key sent as Authorization: Bearer, an ID token instead of an access
token (typ is not at+jwt), or a DPoP-bound token presented without a valid proof.
403 forbidden
The credential is genuine, but this request is not allowed. The message states which rule
failed:
| Cause | message starts with | details |
|---|---|---|
| Missing resource scope (API key or Bearer token) | Insufficient scope: this API key is missing a required scope or … this access token is missing a required scope | required_scopes, granted_scopes |
| Read-only credential on a mutating request | Access denied: this API key has no admin write scope for this operation | none |
| Bearer token without any admin scope | Access denied: this access token was not granted an admin scope | none |
| User or key creator lacks the endpoint's permission | Access denied: missing required permission or Access denied: the API key owner does not hold the required permission | required_permissions |
| Target user holds permissions the caller lacks (password, e-mail, activation, block, delete, authenticators, temporary access code) | You cannot perform this action on an account that holds privileges you do not have. | none |
| Key creator deactivated, blocked or deleted | Access denied: the API key owner is no longer active | none |
| Endpoint accepts no credentials at all | This endpoint does not accept API keys or access tokens. | none |
| Session caller without a CSRF witness or from a foreign origin | CSRF protection: … | none |
Bearer token on a session-only page (/portal, /account) | Bearer access tokens are not accepted on this endpoint; it requires a browser session. (WWW-Authenticate: … error_description="bearer_not_accepted") | none |
| API key on a session-only page | error_description: API keys are not accepted on this endpoint; it requires a browser session. (WWW-Authenticate: ApiKey … error_description="api_key_not_accepted") | none |
{
"error": "forbidden",
"message": "Insufficient scope: this API key is missing a required scope",
"status": 403,
"details": {
"required_scopes": ["admin:users:read"],
"granted_scopes": ["admin:webhooks:read"],
"key_scopes": ["admin:webhooks:read"]
}
}
key_scopes duplicates granted_scopes for older API-key consumers.
{
"error": "forbidden",
"message": "Access denied: missing required permission",
"status": 403,
"details": {
"required_permissions": ["users.delete"]
}
}
required_permissions is any-of: holding one of the listed permissions is enough. Which
permission each endpoint needs is listed under
Required permissions.
400 invalid_request with rejected_keys
PATCH /orgs/{orgId}/api/v1/admin/organization accepts only an allow-list of settings
keys. If the payload contains anything else, or any nested key ending in _hash, _secret,
_token, _key or _password, nothing is written and the response lists every offending
path:
{
"error": "invalid_request",
"message": "Unsupported settings key(s): sandbox, security.scim_token. Writable keys are: display_name, displayName, timezone, locale, security, email, audit_log_retention_days, audit_log_auto_delete, agent_governance.",
"status": 400,
"details": {
"rejected_keys": ["sandbox", "security.scim_token"]
}
}
See Organization settings for the allow-list.
409 conflict
Returned when a permission slug or an OAuth scope name already exists in the organization (or shadows a system-defined one). See Permissions.
OAuth 2.0 Errors
Returned by the authorization, token, and introspection endpoints. The core OAuth 2.0 standard defines these endpoints, the grant types, and the error body shape shown below (error plus an optional error_description).
error | Status | Common cause |
|---|---|---|
invalid_request | 400 | Missing grant_type, invalid JSON body, unsupported content type |
invalid_client | 401 | Wrong client_id, wrong client_secret, client not in organization |
invalid_grant | 400 | Authorization code expired or reused, refresh token revoked |
unauthorized_client | 403 | Client is not authorized for this grant type (400 from the CIBA and device authorization endpoints) |
unsupported_grant_type | 400 | Grant type typo or not enabled |
invalid_scope | 400 | Scope not registered or exceeds client's allowed scopes |
access_denied | 403 | User denied consent, or policy blocked the request |
server_error | 500 | Internal server error |
temporarily_unavailable | 503 | Maintenance or overload |
Authorization API Errors
error | Status | Description |
|---|---|---|
MISSING_PERMISSION | 400 | Required field permission is missing |
INVALID_PERMISSIONS | 400 | permissions must be a non-empty array |
MISSING_FIELDS | 400 | Zanzibar check missing object, relation, or user |
CHECK_FAILED | 500 | Permission check failed due to server error |
ZANZIBAR_CHECK_FAILED | 400 | Invalid tuple format or unknown namespace |
LIST_FAILED | 500 | Failed to retrieve user permissions |
Agent & Token Exchange Errors
error | Status | Description |
|---|---|---|
invalid_target | 400 | Token exchange target (audience) is invalid |
unsupported_token_type | 400 | Revocation endpoint: token_type_hint is not supported. Token exchange never uses it: an unknown or mismatched subject_token_type / actor_token_type is invalid_grant (see Subject and actor token types) |
delegation_not_allowed | 403 | Client is not configured to allow token exchange |
budget_exceeded | 429 | Agent has exceeded its configured budget limits |
capability_denied | 403 | Agent lacks the required capability |
workload_identity_failed | 401 | Failed to verify workload identity token |
Dynamic Client Registration Errors
error | Status | Description |
|---|---|---|
invalid_redirect_uri | 400 | One or more redirect URIs use a non-allowed scheme |
access_denied | 401 | No valid initial access token |
access_denied | 403 | Dynamic registration is disabled for this organization |
invalid_client_metadata | 400 | Client metadata is invalid or unsupported |
invalid_token | 401 | Wrong registration_access_token on the client configuration endpoint |
Validation Errors
When a validation_error is returned, the details object maps each invalid field to a human-readable message:
{
"error": "validation_error",
"message": "Validation failed",
"status": 422,
"details": {
"name": "Name is required",
"redirectUris[0]": "Must be a valid HTTPS URL"
}
}
Rate Limit Errors
The per-credential limit on the Admin API, /api/v1/authz/**, Zanzibar expand, ABAC
check-bulk and /me/** answers with the rich envelope:
{
"error": "rate_limited",
"message": "Too many requests for this credential",
"status": 429
}
Other limiters answer:
{
"error": "rate_limit_exceeded",
"message": "Too many requests. Please retry after 30 seconds.",
"status": 429,
"retryAfter": 30
}
The response also includes the Retry-After HTTP header with the number of seconds to wait.
Error-handling example
import requests, time
class LumoAuthClient:
MAX_RETRIES = 3
def call_api(self, method, url, **kwargs):
for attempt in range(self.MAX_RETRIES):
response = requests.request(method, url, **kwargs)
if response.ok:
return response.json()
if response.status_code == 401:
self.refresh_token()
continue
if response.status_code == 429:
time.sleep(int(response.headers.get("Retry-After", 60)))
continue
if response.status_code >= 500:
time.sleep(2 ** attempt)
continue
err = response.json()
raise LumoAuthError(code=err.get("error"), message=err.get("message") or err.get("error_description"))
raise LumoAuthError(code="max_retries", message="Max retries exceeded")
Standards used on this page
| Plain name | Standard |
|---|---|
| OAuth 2.0 (the core sign-in and token protocol) | RFC 6749 |
| SCIM 2.0 (user and group provisioning) | RFC 7642, RFC 7643 and RFC 7644 |
| Step-up authentication challenge | RFC 9470 |
| Sender-constrained tokens (DPoP) | RFC 9449 |
| JWT access token profile | RFC 9068 |
| Dynamic client registration | RFC 7591 and RFC 7592 |
See the standards reference for what each one covers and how LumoAuth uses it.