Error Handling
Every error the HTTP API returns, grouped by area, with its code, HTTP status, and message.
Error Handling
The built-in HTTP handlers and middleware return errors as JSON with the same shape:
{
"error": "invalid_credentials",
"message": "Invalid email or password"
}error is the stable machine-readable code. Match on it, not message; the message is for people and can change. The HTTP status provides the usual API signal (404 not found, 409 conflict, 429 rate limited, and so on).
This page covers runtime errors from mounted HTTP handlers. For configuration errors returned by NewConfig before the server starts, see Configuration.
invalid_session_context (400) means X-GoAuth-Session has an unsupported or repeated value, or explicitly selects app on the dedicated admin login route. Omit the header for ordinary app requests; use admin consistently for panel requests.
App authorization
| Code | HTTP status | Meaning |
|---|---|---|
app_permissions_disabled | 503 | Permission middleware/capability is unavailable. |
app_permissions_not_initialized | 503 | Trusted initialization has not created app state. |
app_permissions_already_initialized | 409 | Initialization cannot be repeated. |
invalid_permission_key | 400 | Noncanonical or overlong key. |
unknown_library_permission | 400 | Batch/grant includes an unsupported catalog key. |
permission_not_found | 404 | Installed definition is absent or unavailable for granting. |
permission_exists | 409 | Business key already exists. |
permission_in_use | 409 | Business definition still has role grants. |
role_not_found | 404 | Target/configured role is missing or unavailable. |
role_exists | 409 | Role slug already exists. |
role_in_use | 409 | Role is assigned or is the configured default. |
authorization_conflict | 412 for If-Match writes; otherwise 409 | Expected definition/role/assignment revision is stale. |
protected_role | 403 | Attempt to customize a built-in role. |
protected_permission | 403 | Library definitions require protected install/remove rather than business CRUD. |
Permission denials and delegation violations use forbidden (403). Privileged
calls without required session assurance use two_factor_required (403).
Malformed batches/pagination/metadata use invalid_input (400); invalid JSON or
unknown management fields use invalid_json (400). Last usable admin reduction
uses last_admin (400). Refresh data after a revision conflict; do not blindly
overwrite concurrent changes. Missing or disabled business keys deny through
RequirePermission, including for admin.
Handling errors programmatically
Import github.com/nazimdjebloun/go-auth/api for the operation input types in Go examples.
Direct service calls such as auth.Register(...), auth.Services().Auth.Login(...), and auth.Services().Admin.BanUser(...) return a normal Go error, not the JSON envelope. Auth failures are *domain.AuthError values with Code and Message; they intentionally do not contain an HTTP status. The built-in handlers derive a status from Code.
result, err := auth.Services().Auth.Login(ctx, api.LoginInput{Email: email, Password: password})
if err != nil {
if errors.Is(err, domain.ErrInvalidCredentials) {
// matches the exported sentinel for this fixed error
}
var authErr *domain.AuthError
if errors.As(err, &authErr) {
// Same pair as the JSON envelope's "error" and "message" fields.
// The tables on this page map each code to an HTTP status.
}
}Use errors.Is for one specific outcome, such as domain.ErrInvalidCredentials. Use errors.As when you need the code and message generically. This is also how the handlers map an error to a response; an error that is not a *domain.AuthError becomes internal_error (500). Prefer errors.As to a direct type assertion because it also finds an *AuthError inside a wrapped error.
Login, session validation, and initial credential-token lookups distinguish an absent row from a backend failure. These backend read failures are wrapped with context while preserving the cause, so direct callers can use errors.Is(err, context.Canceled) or errors.Is(err, context.DeadlineExceeded). Login lookup failures do not become invalid_credentials, and GetSession backend failures do not become session_expired. Mounted handlers return the generic internal_error response for backend failures. Do not assume every service error is a *domain.AuthError, or expose a raw backend error to clients.
HTTP contract and redirect exception
Authentication, authorization, organization, rate-limit, and CSRF middleware use the same two-field JSON envelope as the handlers, with Content-Type: application/json. Clients can decode failures the same way regardless of which layer rejected the request.
OAuth callback failures remain browser redirects, carrying error and provider query parameters on {BaseURL}/auth/callback. Read the query string on that page instead of expecting a JSON body. Direct OAuth service calls still return Go errors.
Errors produced by your own handlers or router, such as an unmatched route or method, are outside this contract. CORS preflight and successful responses keep their existing response formats.
Organization membership middleware returns org_member_not_found (404) for a non-member and org_forbidden (403) for an insufficient role.
General
| Code | Status | Message |
|---|---|---|
internal_error | 500 | Internal server error |
password_work_busy | 503 | Password processing is busy; try again later |
invalid_json | 400 | Invalid request body (malformed JSON on any endpoint that decodes one) |
invalid_input | 400 | Generic input validation failure (exact message varies, e.g. sessionIds must not be empty) |
forbidden | 403 | You do not have permission |
rate_limit_exceeded | 429 | Too many requests, please try again later |
rate_limit_error | 429 | Service temporarily unavailable |
method_disabled | 405 | This registration method is not available |
internal_error always means: something failed that the caller cannot fix by changing their request (a database error, a failed hash, etc.). It is logged server-side with detail; the client only ever sees the generic message. This includes auth-middleware failures: when session validation, transparent refresh, or the post-rotation user lookup fails because the database is unavailable (or the request context is canceled), the middleware responds 500 internal_error — never a misleading 401. A refresh that has already rotated the session's tokens still delivers the new Set-Cookie headers before that 500, so a client retry uses the current credentials instead of the pre-rotation ones.
password_work_busy means the shared password-work concurrency limit is full;
the KDF was not started. It applies to password hashing and verification across
HTTP and direct service calls. Use errors.Is(err, domain.ErrPasswordWorkBusy)
in Go and retry later with backoff. Login dummy checks propagate the same error;
optional rehashing after successful verification may be skipped instead.
Authentication (register, login, session cookie)
| Code | Status | Message | Hint |
|---|---|---|---|
email_already_exists / account_already_exists | 409 | An account with this email already exists | Both codes mean the same thing; register uses the first. Also returned to the loser of a race between two concurrent registrations for the same email. The database unique constraint is the real backstop, and the loser gets this code, not a 500. |
invalid_credentials | 401 | Invalid email or password | Deliberately the same message whether the email does not exist or the password is wrong. No account enumeration. |
user_banned | 403 | This account has been banned | Returned on login and on every authenticated request once a session resolves to a banned user. |
email_not_verified | 403 | Please verify your email first | Only when RequireEmailVerification is on. |
weak_password | 400 | Password must be at least 8 characters (or the specific policy rule that failed) | From your configured PasswordPolicy. |
invalid_email | 400 | Invalid email format | |
name_required | 400 | Name is required | |
unauthorized | 401 | Invalid session / User not found | From the auth middleware, not a specific service call. Only a genuine credential problem (invalid session, or the session resolved to a missing user). If the database itself fails, you get internal_error 500 instead — see General. |
session_expired | 401 | Missing session cookie / Session has expired | Also from the auth middleware. See Sessions below for the token-level version. |
Two-factor authentication
| Code | Status | Message | Hint |
|---|---|---|---|
two_factor_code_invalid | 400 | Invalid two-factor code | Also returned for a wrong binding cookie and for a code submitted after the lineage's 5-guess cap is hit. The client cannot distinguish "wrong code" from "capped" or "wrong browser," on purpose. |
two_factor_code_expired | 410 | Two-factor code has expired | Also what POST /auth/2fa/resend returns once a lineage is used up (5 wrong guesses or 3 resends). Starting over with a fresh Login/Register call works. |
two_factor_code_already_used | 410 | This two-factor code has already been used | |
two_factor_already_enforced | 409 | Two-factor authentication is required and cannot be changed | Returned by POST /auth/2fa/enable and POST /auth/2fa/disable when TwoFactorConfig.RequireEmail2FA is on. Mandatory 2FA cannot be toggled per-user. |
two_factor_password_required | 400 | Password is required to change two-factor settings | From POST /auth/2fa/enable/disable. Deliberately its own code, not the pre-existing password_required (delete-account). Same shape, different endpoint, different message; sharing a code would make the two indistinguishable to a client matching on error. |
challenge_not_found | 200 | If the challenge is valid, a new code has been sent | HTTP 200 on purpose: POST /auth/2fa/resend never reveals whether a challengeId is real, so an unrecognized or binding-mismatched one gets the same generic response as a real resend. |
email_not_configured | 500 | Email sender is not configured | 2FA requires a mailer the same way email verification does. See Configuration. |
Sessions
| Code | Status | Message | Hint |
|---|---|---|---|
session_not_found | 404 | Session not found | Returned by revoke-by-ID when the session does not exist or belongs to someone else. The two cases are indistinguishable on purpose, so one user cannot probe another's session IDs. |
session_expired | 401 | Session has expired | |
session_revoked | 401 | Session has been revoked | |
invalid_refresh_token | 401 | Refresh token is invalid | |
refresh_expired | 401 | Refresh token has expired | |
token_already_rotated | 409 | This refresh token has already been rotated — use the new one | Expected under concurrent refresh requests; the grace window (SessionConfig.GraceWindow) exists to absorb the common case before this fires. |
max_lifetime_exceeded | 401 | Session lifetime exceeded, please re-authenticate | Only reachable when SessionConfig.MaxLifetime is set. |
invalid_refresh | 401 | No refresh token provided | |
missing_token | 400 | Token is required | |
invalid_input | 400 | sessionIds must not be empty / cannot revoke more than 100 sessions at once | From bulk session revoke. |
Password
| Code | Status | Message | Hint |
|---|---|---|---|
reset_token_invalid | 400 | Invalid password reset token | Also returned if the account behind a previously issued reset token no longer has a password. |
reset_token_expired | 410 | Password reset token has expired | |
reset_token_already_used | 410 | Password reset token has already been used | |
wrong_password | 400 | Password is incorrect / Current password is incorrect | Used both by delete-account and change-password. |
password_update_conflict | 409 | Password changed concurrently; retry the operation | Change-password or reset-password read a credential that another request replaced before the guarded write. The winning hash/version remains stored. Reset's transaction rolls back its token claim; neither losing path revokes sessions. |
no_password | 400 | No password set. Use set-password instead. | For OAuth-only accounts calling change-password instead of set-password. |
already_set | 400 | User already has a password | The reverse case: calling set-password when one already exists. |
invalid_code | 400 | Invalid set password code | |
code_used | 400 | Set password code has already been used | |
email_not_configured | 500 | Email sender is not configured | Also returned by forgot-password for every submitted address when no mailer is configured. |
email_failed | 500 | Failed to send email | Used by other email flows. Forgot-password logs delivery failures and returns its generic response. |
Account (name, deletion, email verification)
| Code | Status | Message | Hint |
|---|---|---|---|
password_required | 400 | Password is required to delete account | |
password_account | 400 | Use DELETE /auth/account with password to delete your account | An account with a password tried the code-confirmation delete flow; that flow is for passwordless accounts. |
delete_code_invalid | 400 | Invalid deletion code | |
delete_code_expired | 410 | Deletion code has expired | |
delete_code_already_used | 410 | Deletion code has already been used | |
validation_error | 400 | Name cannot be empty | |
code_invalid | 400 | Invalid verification code | |
code_already_used | 410 | This code has already been used | |
code_expired | 410 | Verification code has expired | |
already_verified | 400 | Email is already verified | |
email_not_found | 200 | If an account exists, a verification email will be sent | Public resend acknowledges queue acceptance with the same response for known and unknown addresses. Queue failure returns internal_error (500); delivery failures retry in the background. |
Invites (self-service, invite-only signup)
| Code | Status | Message |
|---|---|---|
invite_not_found | 404 | Invite not found |
invite_expired | 410 | This invite has expired |
invite_already_used | 410 | This invite has already been used |
invite_revoked | 403 | This invite has been revoked |
invite_already_exists | 409 | A pending invite for this email already exists |
password_mismatch | 400 | Passwords do not match |
name_required | 400 | Name is required |
OAuth
| Code | Status | Message | Hint |
|---|---|---|---|
provider_not_found | 404 | Unrecognized provider | The {provider} path segment does not match any registered WithProvider. |
provider_already_linked / already_linked | 409 | This provider is already linked to another account | Two codes for the same condition at different call sites. Treat them the same. |
provider_not_linked | 404 | This provider is not linked to your account | |
cannot_unlink_last_provider | 400 | Cannot unlink last login method — set a password first | Prevents locking yourself out of an OAuth-only account. |
invalid_state | 400 | Invalid or expired OAuth state | The anti-CSRF state token on the callback didn't match. |
state_used | 400 | OAuth state token already used | State tokens are single-use. |
state_expired | 400 | OAuth state token has expired | |
provider_error | 502 | Failed to authenticate with provider | The provider's own API rejected the exchange. Not something the caller can fix. |
unauthorized | 401 | Authentication required | Link/unlink require an existing session. |
email_already_exists | 409 | An account with this email already exists | Logging in via OAuth when a password account already exists for that email. go-auth never auto-links the two; the user has to log in with the password and use link explicitly. See OAuth guide. |
Callback errors do not arrive as JSON. See HTTP contract and redirect exception above.
Organizations
Granular org authorization
| Code | Status | Meaning |
|---|---|---|
org_permissions_disabled | 503 | The capability is disabled. |
org_permissions_not_initialized | 503 | The organization owner must initialize authorization. |
org_permissions_already_initialized | 409 | Initialization already completed. |
org_permission_not_found | 404 | An installed org permission was not found. |
org_permission_exists | 409 | The org already has this permission key. |
org_permission_in_use | 409 | Remove role grants before deleting the business definition. |
protected_org_permission | 403 | Only an owner can provision library definitions; custom CRUD cannot edit them. |
org_role_not_found | 404 | The same-org role is absent or unavailable for assignment. |
org_role_exists | 409 | The org already has this role slug. |
org_role_in_use | 409 | A member or invitation references the role. |
protected_org_role | 403 | Built-in org roles cannot be edited or deleted. |
org_authorization_conflict | 412 for If-Match writes; otherwise 409 | A role/definition/assignment revision changed; refresh the resource before retrying the write. |
org_invite_role_changed | 409 | The invitation's saved role changed or is disabled; an authorized inviter must delete and recreate it. Retrying or resending cannot refresh its saved revision. |
Fixed-role and membership errors also apply:
| Code | Status | Message | Hint |
|---|---|---|---|
organizations_disabled | 503 | Organizations are not enabled | Organization middleware was requested while organizations are disabled. |
org_not_found | 404 | Organization not found | |
org_slug_exists | 409 | Organization slug already in use | |
org_slug_reserved | 400 | Organization slug is reserved | |
org_member_not_found | 404 | User is not a member of this organization | Also returned by RequireOrgMember when the membership lookup comes back empty, but only for missing membership. A lookup that fails outright (e.g. the database is down) returns internal_error 500 instead, so a server problem never masquerades as a not-a-member 404. |
org_member_exists | 409 | User is already a member of this organization | |
org_member_conflict | 409 | Organization membership changed concurrently; retry the operation | Two requests raced the same membership (remove vs remove, remove vs role change, or two role changes). The loser's guarded write matched nothing, so its counter updates rolled back. Refetch and retry. |
cannot_remove_last_owner | 400 | Cannot remove or demote the last owner of an organization | |
org_limit_reached | 400 | Maximum organization limit reached for user | OrganizationConfig.MaxOrgsPerUser. |
org_member_limit_reached | 400 | Organization member limit reached | |
org_forbidden | 403 | Insufficient organization permissions | Returned by role-gated actions (e.g. a member trying to change another member's role). |
no_active_org | 400 | Select an active organization first | RequireActiveOrg requires a selected organization in the session. |
org_invite_expired | 400 | Organization invite link has expired | |
org_invite_email_mismatch | 400 | Authenticated email does not match invite recipient | The invite was sent to a specific address; whoever accepts it must be logged in as that address. |
org_metadata_too_large | 400 | Organization metadata exceeds 16KB limit | |
invalid_slug | 400 | Slug must be 255 characters or less | |
invalid_name | 400 | Organization name is required | |
invalid_role | 400 | Invalid organization role | |
invite_not_found | 404 | Invite not found | Same code as the self-service invite system above. Org invites reuse it. |
The org-membership middleware (RequireOrgMember, RequireOrgRole) uses the normal envelope too. See HTTP contract and redirect exception above: a non-member gets the same org_member_not_found 404 a direct OrgService call returns, and an under-privileged member gets org_forbidden 403.
Admin
| Code | Status | Message | Hint |
|---|---|---|---|
already_banned | 400 | User is already banned | |
not_banned | 400 | User is not banned | |
last_admin | 400 | Cannot ban / demote / delete the last admin | The library refuses to leave zero admins standing. |
invalid_role | 400 | Role must be 'user' or 'admin' | |
name_required | 400 | Name is required | |
session_not_found | 404 | Session not found | Admin revoking a specific user session that does not exist. |
user_not_found | 404 | User not found | No user with that {id}. Returned by detail, ban/unban, role change, delete, and the per-user session and audit-log routes. A failed database read while checking the target is internal_error 500, not this code. |
forbidden | 403 | You do not have permission | A non-admin hit an admin-only route. |
two_factor_required | 403 | Sign in again and complete two-factor authentication | The admin session lacks required second-factor assurance. |
CSRF and rate limiting
These failures use the normal JSON envelope. CSRF checks still reject the same requests; only their error representation has changed from plain text to JSON.
| Code | Status | Cause |
|---|---|---|
csrf_headers_missing | 403 | Neither an allowed Origin nor a Referer was sent; both cannot be missing. |
csrf_origin_denied | 403 | The request's Origin or Referer is not allowed. |
csrf_token_missing | 403 | The double-submit cookie or header is missing. |
csrf_token_invalid | 403 | The signature or browser/session binding is invalid. |
csrf_token_mismatch | 403 | The header does not match the signed cookie. |
internal_error | 500 | CSRF identity lookup, signing configuration, or token generation failed. |
rate_limit_exceeded | 429 | The rate limit was exceeded. |
rate_limit_error | 429 | The rate-limit store failed; the middleware fails closed. |
Both rate-limit errors include Retry-After. Clients should respect that delay and match the JSON error code, rather than parsing human-readable messages.
Verification required on an existing session
403 email_not_verified means the session is still present but the current
email-verification policy forbids protected access. Keep the session and route
the user to verification. GET /auth/me returns a minimal status with
step: "verify_email", and verification, resend and logout remain
available. Refreshing or retrying the protected operation cannot bypass this
requirement. Fetch the full profile after successful verification.
method_disabled also applies when an outstanding password reset/setup token,
OAuth callback or signup invitation is completed after its method/path was
disabled. Optional 2FA email confirmations use two_factor_code_invalid for an
invalid, expired, wrong-session or wrong-action proof.
Email-change errors
email_change_disabled(403): the optional capability is disabled.email_change_unavailable(409): unchanged or unavailable target address; occupancy is checked only after both mailbox proofs.email_change_invalid(400): incorrect codes, consumed/cancelled/replaced request, wrong initiating session/context, or exhausted guesses.email_change_expired(410): expiry or a request predating OTP pepper rotation.
Existing authentication, verification and MFA failures remain applicable.
Wrong passwords return invalid_credentials (401); limits return
rate_limit_exceeded (429); proof-email delivery failures return email_failed
(500). Infrastructure failures retain the generic internal_error envelope.
Completion-notice failures are retried outside the successful transaction.
Redis infrastructure failures
Redis snapshot failures fall back to authoritative SQL. Rate-limit backend failures retain 429 rate_limit_error and Retry-After: 60; exhausted budgets return rate_limit_exceeded. Notification counter failures never change login authorization. Canceled HTTP requests stop before downstream execution. Redis does not add a credential error code.