# Errors & Troubleshooting

> The error formats nauthera-server returns and what their codes mean, tracing a failed request by its request id, and fixes for the most common problems.

## Error formats

| Endpoints | Format |
|---|---|
| `/authorize`, `/par`, `/token`, `/device_authorization`, `/introspect`, `/revoke`, `/end_session` | The OAuth error object (RFC 6749 §5.2): `error`, `error_uri` (the error's [runbook](https://www.nauthera.io/docs/v1/runbooks/oauth)) and, except on a direct `/authorize` refusal, `error_description`. A request these endpoints cannot read, one that repeats a form parameter, or one that fails schema validation gets a problem document instead. |
| `/register` | The registration error object (RFC 7591 §3.2.2), for example `invalid_redirect_uri` or `invalid_client_metadata`. A body that cannot be read, is missing or fails schema validation gets a problem document instead, so a `redirect_uris` entry that is not an absolute URI answers `validation_error`. |
| `/userinfo` | A problem document (RFC 9457). A `401` carries `invalid_token`, `invalid_dpop_proof` or `use_dpop_nonce`, and a `WWW-Authenticate` challenge (RFC 6750 §3, RFC 9449 §7.1). A `500` carries `server_error` and no challenge. |
| Everything else, and on every endpoint a generic `500` or a request refused before its handler runs: the rate limit (`rate_limited`, or no `code` on the hosted UI's pages, an organization's own discovery documents and JWKS, and the public `/healthz`), a repeated header (`invalid_request`), a parameter or body that cannot be read or a repeated form parameter (`malformed`), a body larger than the server reads (`body_too_large`), a missing body (`required`), or a schema failure (`validation_error`) | A problem document (RFC 9457), `Content-Type: application/problem+json` |

`/authorize` sends an error to the client's callback only once it has validated the
redirect URI, and some errors are answered directly even then. Apart from the
problem documents above, a direct refusal carries only `error: invalid_request`, its
`error_uri` and `iss`, whatever the cause. The reason is recorded in the audit log and on the trace,
under the request id.

A browser that navigates to the main organization's `/authorize` or `/end_session` and
hits an error gets an HTML page instead of the JSON body. The page does not give the
reason. It shows the request id as a Reference ID; look it up in the logs and audit
events. On every other path, `/orgs/{slug}/authorize` included, a browser navigation gets
an HTML page only for a 404, 405, 429 or 5xx.

A problem document looks like this:

```json
{
  "type": "https://nauthera.io/problems/invalid_credentials",
  "title": "Unauthorized",
  "status": 401,
  "code": "invalid_credentials",
  "detail": "invalid email or password"
}
```

Problems written outside an endpoint's handler also carry `instance`, `request_id` and,
when the request was traced, `trace_id`: a rate-limit refusal, a request that cannot be
read or fails validation, a repeated header, an unknown `/v1/` path or organization, and
a generic `500`.

Branch on `code`, not on `detail`. Validation failures add an `errors` array with one
`{field, code, message}` entry per field, using codes such as `required`, `too_short`,
`too_long`, `pattern`, `invalid_format` and `not_allowed`. Problem documents carry no
internal detail: database errors, stack traces and secrets are logged on the server and
never returned.

Every response carries `X-Request-ID`, and `X-Trace-Id` when the request was traced.
Quote the request id when you report a problem; it finds the matching log lines and
audit events.

### Tracing a failed request

The sign-in app's error screens show the request id as a **Reference ID**, with a button
that copies it. An administrator pastes it into the [admin dashboard](https://www.nauthera.io/docs/v1/admin-dashboard)'s
**Troubleshooting** page, which reads every audit record carrying that id from the last 90
days and shows what happened: the record that decided the outcome, its reason, the
endpoint, account, application, address, browser, trace id and session, and every record
the request left, in order. It reads the audit log, so it needs the same permission as
the [audit log](https://www.nauthera.io/docs/v1/audit-log#querying). Through the API, the same lookup is the
`request_id` filter of `GET /v1/admin/orgs/{slug}/audit-events`. An id behind a proxy you
trust is the proxy's, since a trusted proxy may set `X-Request-ID` itself.

### Problem codes

The server sends the 43 codes below, the list the OpenAPI document declares as
`ProblemCode`. A problem with a `code` has `type` `https://nauthera.io/problems/` followed
by the code, which opens the code's [runbook](https://www.nauthera.io/docs/v1/runbooks/problems): why it
happened, who is affected and what to do. A problem without one, such as a generic `500`,
has `type: about:blank`. The codes
`required` and `malformed` here are not the per-field codes of the same names in
`errors`.

| Code | Status | Meaning |
|---|---|---|
| `validation_error` | 400 | The request failed schema validation. See `errors`. |
| `malformed` | 400 | A parameter or body could not be read, or a form parameter was repeated. Sent before the handler runs, OAuth endpoints included. |
| `body_too_large` | 413 | The body is larger than the server reads (`server.http.max_body_bytes`). Sent before the handler runs. |
| `required` | 400 | A required request body is missing. Sent before the handler runs. |
| `invalid_request` | 400 | A handler refused the request, or a header that must appear once was repeated |
| `invalid_credentials` | 401 | Wrong email or password |
| `unauthorized` | 401 | No usable credential: no sign-in session, a lapsed pending sign-in, or an admin token that is absent, does not verify or fails its DPoP check |
| `insufficient_user_authentication` | 401 | An admin operation that changes a credential or removes an account needs a recent sign-in, with a second factor where required. Sign in again and replay the call. |
| `invalid_token` | 401 | `/userinfo`: no access token; one that is invalid, expired or meant for another audience; or a DPoP-bound token sent as `Bearer` or with another key's proof. |
| `invalid_dpop_proof` | 401 | `/userinfo`: the DPoP proof for a bound token is missing or not valid for this request. |
| `use_dpop_nonce` | 401 | `/userinfo`: retry with the nonce sent in the `DPoP-Nonce` header. |
| `forbidden` | 403 | Not allowed: a hosted-UI POST that looks cross-site, or an admin call without the permission or scope it needs, from a token not issued to one of its organization's admin clients, or outside the organizations the caller may manage |
| `protected_account` | 403 | An admin call would change the status, credentials, email address or username of, or delete, the caller's own account or one more privileged than the caller; or would grant or revoke a role on the caller's own account, grant a platform role outside the main organization, or grant a role that holds a permission the caller lacks |
| `password_held_by_directory` | 409 | An administrator asked to set, reset or require a change of the password of an account that signs in through the organization's [LDAP or Active Directory connection](https://www.nauthera.io/docs/v1/ldap-directory-resource), or to invite it to choose one. The directory holds that password, and its own tools set, reset and expire it. Nothing changed. |
| `last_role_administrator` | 409 | Revoking the role would leave the organization with no other active account that can grant roles. Nothing changed. |
| `invalid_code` | 401 | A wrong MFA or recovery code |
| `not_found`, `unknown_org` | 404 | No such resource, or no such organization in the path. `not_found` also answers a sign-in that names no client registered in the organization. |
| `conflict` | 409 | A concurrent change, or a duplicate |
| `registration_conflict` | 409 | Self-registration with an email that already exists |
| `registration_disabled` | 403 | Self-registration is off |
| `already_enrolled` | 409 | The user already has the second factor they are trying to enrol |
| `too_many_requests` | 429 | A throttle tripped: sign-in and registration attempts from one IP, failed sign-ins for one email, second-factor attempts, or no password-hashing slot came free in time. While `auth.brute_force.reveal_lockout` is off, a locked account gets this code too, even with the right password. Retry later. See [Rate Limits & Lockout](https://www.nauthera.io/docs/v1/rate-limits#what-the-sign-in-screen-sees). |
| `too_many_attempts` | 429 | An attempt budget is spent, for example five wrong device codes. Retrying will not help; start over. |
| `account_locked` | 429 | The account is locked (only with `reveal_lockout` on) |
| `account_unavailable` | 403 | The right password for an account an administrator suspended or blocked (only with `reveal_lockout` on; otherwise `invalid_credentials`) |
| `captcha_failed` | 400 | The request carried no captcha solution, or one that is wrong, has expired or was already used. Solve a new one. See [Rate Limits & Lockout](https://www.nauthera.io/docs/v1/rate-limits#captcha). |
| `invalid_reset_link` | 400 | A password-reset link that was never issued, has expired, was already used or is another organization's. Ask for a new one. |
| `password_reused` | 400 | The new password is the same as the one the account must replace. Choose another. |
| `password_too_short` | 400 | The new password has fewer characters than `auth.password.min_length`. Choose a longer one. |
| `password_too_long` | 400 | The new password has more characters than `auth.password.max_length`. |
| `password_compromised` | 400 | The new password is a common one (`auth.password.refuse_common`) or the breach lookup (`auth.password.breach_lookup`) found it in a breach corpus. Choose another. |
| `invalid_verification_link` | 400 | An email-verification link that was never issued, has expired, was already used, is another organization's, or was sent to an address the account no longer holds. Ask for a new one. |
| `invalid_invitation_link` | 400 | An invitation link that was never issued, has expired, was already used, is another organization's, or was sent to an account that has a password or another address by now. **Request a new link** on the page opens `/forgot-password`, which sends a new invitation to an account that still waits on one and a reset link to one that has a password. |
| `mail_relay_missing` | 409 or 503 | An invitation, an administrator's password-reset link or an emailed sign-in code cannot be sent, because the organization has no [email relay](https://www.nauthera.io/docs/v1/organizations#email). The admin API answers `409`; the emailed second factor answers `503`. |
| `email_unverified` | 409 | The email second factor cannot be enrolled because the account's address is not verified. Verify it first. |
| `rate_limited` | 429 | The per-IP rate limit. Honour `Retry-After` when it is sent. |
| `unavailable` | 503 | The feature is not available in this deployment, or something it depends on could not answer: a hosted captcha provider, or, with `audit.fail_closed` on, the audit log |
| `busy` | 503 | An audit count was turned away because the server is computing as many as it allows, or it ran out of time. Honour `Retry-After`. |
| `window_too_large` | 400 | An audit count asked for a window longer than its bucket allows |
| `too_many_series` | 400 | An audit count would return too many series times buckets. Narrow the filters or the window, or choose a coarser bucket. |
| `internal_error` | 500 | The server accepted the request and could not finish it, for example a logout whose session could not be revoked. Do not assume it took effect. Other server faults are a `500` with no `code`, apart from `server_error` on `/userinfo`. Check the logs by request id. |
| `server_error` | 500 | `/userinfo`: the token could not be checked. No challenge is sent. Keep the token and retry. |

## The server refuses to start

The server checks its configuration before opening any listener and names every problem
it finds. The common ones:

| Message mentions | Fix |
|---|---|
| `issuer.url must use https` | Every issuer is `https`, including local ones. `just certs`, which `just up` and `just dev` run for you, creates a local certificate. A plaintext listener behind a TLS-terminating proxy is fine; the issuer names what clients reach. |
| `issuer.url must not contain a path` | Give the server its own host or subdomain. Every route is served from the origin root. |
| `cache.enabled=false is unsafe` (the Helm chart says `cache.enabled is false`) | A non-loopback issuer needs Redis. Set `cache.enabled` and `cache.address`. |
| `keys.provider` or `mfa.provider` is `memory` | A non-loopback issuer needs `file` providers, so keys and TOTP seeds survive restarts. `mfa.required: true` needs `mfa.provider: file` on every issuer. |
| `security.trusted_proxies entry … trusts every address` | List your proxies' own addresses or ranges, not `0.0.0.0/0` or `::/0`. |
| `security.client_ip.mode=… needs security.trusted_proxies` | A forwarded mode believes an address only from a trusted proxy. List your proxies, or use `mode: socket`. |
| `server.grpc.allow_insecure requires a loopback server.grpc.address` | Turn on mTLS (`server.grpc.tls.enabled`), or bind the plaintext listener to `127.0.0.1`. |
| `redis may evict keys` | Set the Redis `maxmemory-policy` to `noeviction`. Every key carries a TTL, so `volatile-*` policies evict them too. |
| A migration is unapplied, or no audit partition covers this month, naming `maintain` | With `audit.partition_maintenance: external`, run `nauthera-server maintain` with the owning credential before the server starts. |
| `mfa.drift … is out of range` | Use 0, 1 or 2. |
| `rate_limit.backend=redis requires cache.enabled=true` | Turn on the cache, or use the `memory` backend. |
| An `audit.include` or `audit.exclude` pattern matches no audit action | Fix the typo. The message points to `/v1/admin/audit-events/catalog`, but the catalog is served at `GET /v1/admin/audit-catalog`. See [Audit Log](https://www.nauthera.io/docs/v1/audit-log#levels). |
| `fips.required` | Run a binary built with `GOFIPS140=v1.0.0`, and do not set `GODEBUG=fips140=off`. The message also asks for `GODEBUG=fips140=on`, which such a binary does not need. |

The Helm chart runs the same checks when it renders, so most of these fail
`helm install` with the fix attached.

These fail **quietly** rather than at startup:

- **A `--config` path that does not exist is ignored**, and the server starts on defaults
  and environment variables. Check the startup log for the configuration it loaded.
- **An unset `keys.encryption_key`** only logs a warning. See
  [Keys & Secrets](https://www.nauthera.io/docs/v1/keys-and-secrets#the-key-encryption-key).
- **An empty mounted secret file** is not refused. It sets its key to an empty value, which
  outranks the YAML file and the environment, and no message says the file was empty. A
  key the startup checks require, such as `mfa.encryption_key` with the `file` provider,
  then fails them as if it were unset. See
  [Configuration](https://www.nauthera.io/docs/v1/configuration#secrets-from-files).

## Common problems

**Sign-in is throttled for everyone at once.** The server sees every request coming from
your proxy's address, so all users share one rate-limit budget and one brute-force
throttle. List the proxy in `security.trusted_proxies`; the dashboard's overview names the
proxies it saw. See [Configuration](https://www.nauthera.io/docs/v1/configuration#the-client-address).

**Sign-ins fail at the token exchange while Redis is down.** Authorization codes live
in Redis, so `/authorize` cannot complete and `/token` cannot redeem a code until Redis
answers again. The rate limit does not refuse anyone for the outage: with
`rate_limit.backend: redis`, each replica limits on its own bucket meanwhile and
`nauthera_rate_limit_degraded` reads `1`. Refresh tokens and revocations live in the
database and keep working, except during the first refresh-token lifetime after an
upgrade from a build that kept them in Redis, when Redis is still read. Check Redis.

**The sign-in page answers `404`.** A sign-in must continue an application's request:
the hosted sign-in screen, `POST /v1/auth/sign-in` and the passkey sign-in answer `404`
unless they name a `client_id` registered in the organization, or continue the device
flow. Send users through the application, or through `/authorize`, not to `/login`
directly. Screens reached without an application, such as the end of an invitation,
tell the user to go back to the application they came from.

**`redirect_uri` is rejected.** Redirect URIs are compared exactly, including the scheme,
port, path and trailing slash, so a loopback redirect must name the port it was
registered with.

**`/orgs/default/…` answers `404 unknown_org`.** The main organization is served at the
root only. Use `/authorize`, not `/orgs/default/authorize`.

**A refresh answers `invalid_grant`.** The refresh token was used before (refresh tokens
rotate, and reuse revokes the whole family), it expired, its session was ended by a
logout or an administrator, or every grant of the account was retired by a password
reset, a change of second factor or an administrator ending all of the user's sessions.
A suspended or blocked account is refused the same way. The description is the same for
all of these on purpose.
Send the user through `/authorize` again, and make sure your application never sends two
refreshes with the same token. See
[Connect an Application](https://www.nauthera.io/docs/v1/integration#8-refresh).

**A sign-in or registration POST answers `403 forbidden`, "cross-site request
rejected".** The hosted UI's JSON endpoints accept only `Content-Type: application/json`
from the issuer's own origin. Browsers send `Sec-Fetch-Site: same-origin`; `same-site`
from a sibling subdomain is refused, and without that header `Origin` must equal the
issuer. Serve the UI from the issuer, and don't let a proxy rewrite these headers.

**A request answers `400 invalid_request` about a repeated header.** `Authorization`,
`DPoP`, `DPoP-Nonce`, `Cookie`, `Origin`, `Sec-Fetch-Site` and `Content-Type` may
appear only once. A proxy that appends instead of replacing one of them causes this.
Each occurrence is recorded as an `http.header.repeated` audit event.

**Users of another organization cannot sign in after a restart.** The KEK was not set,
so the organization's signing keys were sealed with a key that no longer exists. Set
`keys.encryption_key` before creating organizations. Keys sealed under a lost KEK cannot
be recovered. With more than one replica the failure shows before any restart: each
replica makes up its own key, so the organization works only on the replica that created
it. Identity provider client secrets are sealed under the same key, so sign-in through
any organization's providers, the main one's included, fails the same way.

**Users must re-enroll TOTP after a restart.** The server ran with `mfa.provider: memory`,
which the startup checks allow only on a loopback issuer, and only while `mfa.required`
is off.

**Native apps show the consent screen on every sign-in.** A public client whose redirect
URI is not `https` is always asked, because another app could claim the same redirect.
See [Sessions, Consent & Logout](https://www.nauthera.io/docs/v1/sessions#consent).

**The operator reports `Unreachable` after certificates were renewed.** The server reads
its gRPC certificate, key and client CA again at each handshake, and keeps the last good
material when a replacement cannot be used, logging it once. Check the server's log for
that message and the renewed files themselves. The HTTP certificate, by contrast, is read
only at startup.

**The admin API answers `403` for a correct token.** Two checks must both pass: the token
carries the operation's scope, and the caller holds a role that grants the operation's
permission. A refusal for a missing permission names it in the problem's `detail`. A
user's roles are read from the database on every call, not from the token, so a token
that still lists a removed role is refused, and a role added later counts without a new
token. A `client_credentials` token's roles are the ones its admin client was provisioned
with. Only a token issued to one of its organization's reserved admin clients
(`{slug}-admin-console`, `{slug}-admin-m2m`) is accepted. A token acts only in the
organization that issued it; reaching another organization needs a platform role
(`platform-viewer`, `platform-auditor` or `platform-owner`) held in the main
organization. A call that would change the caller's own account or a more privileged one
answers `403 protected_account` whatever the caller holds. Each refusal is
recorded as an `admin.access.denied` audit event. See
[Admin API & Users](https://www.nauthera.io/docs/v1/user-management#authorization).

**A migration Job.** `nauthera-server maintain` applies the migrations, maintains the
audit partitions and exits, non-zero if a step failed. A release whose migration adds an
audit index makes it build that index on every retained partition, which can take far
longer than the migrations; allow for it in the Job's deadline. See
[Backups & Upgrades](https://www.nauthera.io/docs/v1/operations#schema-migrations).

**A privileged admin change answers `503 unavailable` while sign-in works.** With
`audit.fail_closed: true`, admin and second-factor changes are refused while the audit
log cannot record them. Check the database and the `audit` readiness check; the
condition clears once audit writes succeed again.
