Errors & Troubleshooting
Docs version v1 (main)Nauthera is pre-1.0, in a design-partner programUpdated
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) 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: |
/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:
{
"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'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. Through the API, the same lookup is the
request_id filter of GET /. 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: followed
by the code, which opens the code's runbook: 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.). 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_ | 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_ | 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, 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. is off, a locked account gets this code too, even with the right password. Retry later. See Rate Limits & Lockout. |
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. |
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.) or the breach lookup (auth.) found it in a breach corpus. Choose another. |
invalid_ | 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. 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. | 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. | Give the server its own host or subdomain. Every route is served from the origin root. |
cache. (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. | List your proxies' own addresses or ranges, not 0.0.0.0/0 or ::/0. |
security. | A forwarded mode believes an address only from a trusted proxy. List your proxies, or use mode: socket. |
server. | 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., run nauthera-server maintain with the owning credential before the server starts. |
mfa. | Use 0, 1 or 2. |
rate_ | 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 /, but the catalog is served at GET /. See Audit Log. |
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
--configpath 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_keyonly logs a warning. See Keys & Secrets. - 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_keywith thefileprovider, then fails them as if it were unset. See Configuration.
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.
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_, each replica limits on its own bucket meanwhile and
nauthera_ 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.
A sign-in or registration POST answers 403 forbidden, "cross-site request
rejected". The hosted UI's JSON endpoints accept only Content-Type:
from the issuer's own origin. Browsers send Sec-Fetch-Site:; 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.
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.
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.
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.