Error Codes
BluAuth's JSON APIs return failures using a single, versioned envelope. The full list of codes is defined in shared/types/api-errors.ts and produced by the factory methods in shared/utils/api-error.ts.
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Human-readable detail",
"status": 400,
"requestId": "req_01H...",
"fields": { "email": { "code": "invalid_format", "message": "Invalid email" } }
}
}
requestId is always present — log it on the caller side so Sentry issues can be correlated to specific client failures. fields is present only on VALIDATION_ERROR responses derived from Zod issues. debug (with stack, cause, timestamp, service, version) is attached in non-production stages.
Rules of engagement
- Log
code, notmessage.codeis stable across releases;messageis human-readable and may be changed or translated. - Do not parse
message. Localize againstcodeon the caller side if you need translation. - On
RATE_LIMITEDhonorRetry-After. Do not retry instantly — the limiter uses a moving window. - Distinguish 401 vs 403. 401 = "we don't know who you are, re-authenticate." 403 = "we do, but you can't do that." They require different UX and telemetry.
- Retry only idempotent requests on 5xx.
INTERNAL_ERROR,SERVICE_UNAVAILABLE,REAUTH_REQUIRED,UPSTREAM_PROVIDER_ERRORare transient-looking but have different recovery semantics — see the section on each.
Code reference
The canonical list from ErrorCode in shared/types/api-errors.ts:
| Code | HTTP | Factory | Category |
|---|---|---|---|
VALIDATION_ERROR | 400 | ApiError.validation(...) | Client / validation |
AUTHENTICATION_REQUIRED | 401 | ApiError.unauthorized(...) | Auth |
PERMISSION_DENIED | 403 | ApiError.forbidden(...) | Auth |
RESOURCE_NOT_FOUND | 404 | ApiError.notFound(...) | Not found |
RESOURCE_CONFLICT | 409 | ApiError.conflict(...) | Conflict |
RATE_LIMITED | 429 | (emitted by rate limiter) | Rate limit |
INTERNAL_ERROR | 500 | ApiError.internal(...) | Server |
SERVICE_UNAVAILABLE | 503 | ApiError.serviceUnavailable() | Server |
BUSINESS_RULE_VIOLATION | 422 | ApiError.businessRule(...) | Client / semantics |
INVALID_TOKEN | 401 | ApiError.invalidToken(...) | Provider token brokering |
UNAUTHORIZED_CLIENT | 403 | ApiError.unauthorizedClient() | Provider token brokering |
NO_LINKED_ACCOUNT | 404 | ApiError.noLinkedAccount() | Provider token brokering |
INSUFFICIENT_SCOPE | 403 | ApiError.insufficientScope() | Provider token brokering |
REAUTH_REQUIRED | 502 | ApiError.reauthRequired() | Provider token brokering |
UPSTREAM_PROVIDER_ERROR | 502 | ApiError.upstreamProviderError() | Provider token brokering |
Field error codes
fields[<path>].code from FieldErrorCode:
| Code | Meaning |
|---|---|
required | Missing field that Zod flagged required |
invalid_type | Wrong JS type (string/number/boolean mismatch) |
too_small | Below the minimum length / numeric bound |
too_large | Above the maximum length / numeric bound |
invalid_format | Failed .email(), .uuid(), .url(), regex, etc. |
invalid_enum | Not one of z.enum([...]) allowed values |
custom | Custom .refine(...) validator |
fields[<path>] may also include received (the supplied value) and expected (a human description).
By category
Validation — client-side fixes required
VALIDATION_ERROR (400)
Raised by ApiError.validation(...) when the payload fails Zod parsing or when a handler needs to reject malformed input before hitting the database. fields enumerates every failed path.
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"status": 400,
"requestId": "req_01H...",
"fields": {
"email": { "code": "invalid_format", "message": "Invalid email" },
"password": { "code": "too_small", "message": "Password must be at least 8 characters." }
}
}
}
Resolve: surface fields[...] under the appropriate form controls. Re-submit with corrected values. Do not retry identical payloads.
BUSINESS_RULE_VIOLATION (422)
Raised by ApiError.businessRule(...) when the request is syntactically valid but violates a domain invariant. Example: attempting to reset a password for a user who only signs in via enterprise SSO when allowPasswordForSsoUsers = false.
Resolve: read message for the specific rule. Either adjust the request (e.g. link a credential account first) or relax the rule in service_settings.
Authentication & authorization
AUTHENTICATION_REQUIRED (401)
No session cookie, invalid session token, or an expired Better Auth session.
{
"success": false,
"error": { "code": "AUTHENTICATION_REQUIRED", "message": "Authentication required", "status": 401, "requestId": "req_..." }
}
Resolve: redirect the user to /login (or the hosted login with a client-bound client_slug). Pop the session cookie.
PERMISSION_DENIED (403)
Authenticated, but role or scope insufficient. Emitted by requireAdminRole when a non-admin hits /api/admin/**, or by resource-scoped checks that accept an owner but not the requester.
Resolve: do not retry with the same credentials. Escalate to an admin, or request additional scopes via a re-authorize flow.
Not found
RESOURCE_NOT_FOUND (404)
Raised by ApiError.notFound(resource, id?). message follows the pattern "<resource> with id '<id>' not found" or "<resource> not found".
{
"success": false,
"error": { "code": "RESOURCE_NOT_FOUND", "message": "User with id '...' not found", "status": 404, "requestId": "req_..." }
}
Resolve: verify the ID. Do not retry.
Conflict
RESOURCE_CONFLICT (409)
Raised by ApiError.conflict(...) when the write conflicts with existing state. The admin user-create endpoint returns a special 409 with data payload containing the existing user's id, authMethod (sso or password), and linkedProviders so the caller can decide whether to link.
{
"success": false,
"error": { "code": "RESOURCE_CONFLICT", "message": "User already exists as an enterprise SSO account", "status": 409, "requestId": "req_..." },
"data": { "id": "...", "email": "...", "authMethod": "sso", "linkedProviders": ["google"], "hasCredentialAccount": false }
}
Resolve: inspect data, then link the existing account instead of creating a new one.
Rate limit
RATE_LIMITED (429)
Emitted by the platform-level rate limiter (per-IP or per-session depending on the route). Always accompanied by a Retry-After header.
Resolve: honor Retry-After. If repeated, back off exponentially. Excessive 429s for the same principal is a signal to stop — not to retry harder.
Server errors
INTERNAL_ERROR (500)
Raised by ApiError.internal(...) or surfaces from an unhandled exception. Sentry was notified and the request is tagged with requestId.
Resolve: surface a generic error to the user, file the requestId if reproducible. Safe to retry idempotent GETs after a short delay.
SERVICE_UNAVAILABLE (503)
Raised by ApiError.serviceUnavailable(...) — typically when a dependency is down (Valkey, SES, KMS) or the process is still warming. /api/health returns 503 with { status: "unhealthy" } when DB or cache checks fail.
Resolve: retry with exponential backoff. If persistent, check CloudWatch alarms and the health endpoint.
Provider token brokering
These codes only appear on /api/provider-tokens/:provider. They distinguish client-authorization, user-linkage, and upstream failures. See the provider token brokering guide for full flow.
INVALID_TOKEN (401)
Missing or unknown Authorization: Bearer on /api/provider-tokens/:provider.
{
"success": false,
"error": { "code": "INVALID_TOKEN", "message": "Access token is expired or invalid", "status": 401, "requestId": "req_..." }
}
Resolve: re-fetch a fresh BluAuth access token via the authorization-code flow.
UNAUTHORIZED_CLIENT (403)
The authenticating client is not listed in the provider's allow-list (oauth_clients.allowedProviderTokens).
{
"success": false,
"error": { "code": "UNAUTHORIZED_CLIENT", "message": "Client is not authorized to access provider tokens for 'google'", "status": 403, "requestId": "req_..." }
}
Resolve: have an admin add the provider slug to the client's allowedProviderTokens.
NO_LINKED_ACCOUNT (404)
The authenticated user has no row in accounts for the requested provider. Common on first-time use.
Resolve: bounce the user through /oauth2/login?provider=<slug>&intent=link to connect the account.
INSUFFICIENT_SCOPE (403)
A grant exists but does not cover the requiredScopes in the request body. message includes both granted and required sets.
{
"success": false,
"error": {
"code": "INSUFFICIENT_SCOPE",
"message": "Insufficient scopes for provider 'google'. Granted: [openid, email]. Required: [openid, email, https://www.googleapis.com/auth/calendar.readonly]",
"status": 403,
"requestId": "req_..."
}
}
Resolve: bounce the user through /oauth2/login?provider=<slug>&client_id=<id>&additional_scopes=<space-delimited> to upgrade the grant.
REAUTH_REQUIRED (502)
Upstream refresh token was revoked or is otherwise unusable. Bluauth does not silently create a new grant — the user must re-authenticate.
Resolve: bounce the user through /oauth2/login?provider=<slug>&intent=link. Treat as a non-retryable 502.
UPSTREAM_PROVIDER_ERROR (502)
The upstream IdP returned a transient failure during refresh (5xx, network timeout, etc.).
Resolve: retry with backoff. Persistent failures should be escalated — check provider status pages.
Protocol-endpoint errors
OIDC (/api/oidc/**), legacy OAuth (/oauth2/**), and discovery (/.well-known/**) routes follow their respective specs, not this envelope.
- OIDC authorization / token endpoints return
{"error": "invalid_grant", "error_description": "..."}per OIDC Core §3.1.2.6 and OAuth 2.0 §5.2. Common codes:invalid_request,invalid_client,invalid_grant,unauthorized_client,unsupported_grant_type,invalid_scope. /api/oidc/token/introspectalways returns 200 with{"active": true|false}per RFC 7662./api/oidc/token/revokealways returns 200 with{"ok": true}per RFC 7009.- **
/.well-known/**** returns 200 with the document or 404 if not applicable. /oauth2/loginand/oauth2/callbackredirect with anerrorquery parameter on failure (consent_denied,provider_error,missing_params,invalid_state,exchange_failed,session_failed,link_session_mismatch,redirect_not_allowed). Thereasonparameter narrowssession_failed/link_failedcases (account_claimed_by_other_user,provider_already_linked,ambiguous_email_alias,user_not_found,user_create_failed,internal).
These routes never return the BluAuth JSON envelope, by design — they must be consumable by standards-compliant OAuth/OIDC clients.