go-auth

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

CodeHTTP statusMeaning
app_permissions_disabled503Permission middleware/capability is unavailable.
app_permissions_not_initialized503Trusted initialization has not created app state.
app_permissions_already_initialized409Initialization cannot be repeated.
invalid_permission_key400Noncanonical or overlong key.
unknown_library_permission400Batch/grant includes an unsupported catalog key.
permission_not_found404Installed definition is absent or unavailable for granting.
permission_exists409Business key already exists.
permission_in_use409Business definition still has role grants.
role_not_found404Target/configured role is missing or unavailable.
role_exists409Role slug already exists.
role_in_use409Role is assigned or is the configured default.
authorization_conflict412 for If-Match writes; otherwise 409Expected definition/role/assignment revision is stale.
protected_role403Attempt to customize a built-in role.
protected_permission403Library 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

CodeStatusMessage
internal_error500Internal server error
password_work_busy503Password processing is busy; try again later
invalid_json400Invalid request body (malformed JSON on any endpoint that decodes one)
invalid_input400Generic input validation failure (exact message varies, e.g. sessionIds must not be empty)
forbidden403You do not have permission
rate_limit_exceeded429Too many requests, please try again later
rate_limit_error429Service temporarily unavailable
method_disabled405This 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.

CodeStatusMessageHint
email_already_exists / account_already_exists409An account with this email already existsBoth 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_credentials401Invalid email or passwordDeliberately the same message whether the email does not exist or the password is wrong. No account enumeration.
user_banned403This account has been bannedReturned on login and on every authenticated request once a session resolves to a banned user.
email_not_verified403Please verify your email firstOnly when RequireEmailVerification is on.
weak_password400Password must be at least 8 characters (or the specific policy rule that failed)From your configured PasswordPolicy.
invalid_email400Invalid email format
name_required400Name is required
unauthorized401Invalid session / User not foundFrom 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_expired401Missing session cookie / Session has expiredAlso from the auth middleware. See Sessions below for the token-level version.

Two-factor authentication

CodeStatusMessageHint
two_factor_code_invalid400Invalid two-factor codeAlso 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_expired410Two-factor code has expiredAlso 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_used410This two-factor code has already been used
two_factor_already_enforced409Two-factor authentication is required and cannot be changedReturned 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_required400Password is required to change two-factor settingsFrom 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_found200If the challenge is valid, a new code has been sentHTTP 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_configured500Email sender is not configured2FA requires a mailer the same way email verification does. See Configuration.

Sessions

CodeStatusMessageHint
session_not_found404Session not foundReturned 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_expired401Session has expired
session_revoked401Session has been revoked
invalid_refresh_token401Refresh token is invalid
refresh_expired401Refresh token has expired
token_already_rotated409This refresh token has already been rotated — use the new oneExpected under concurrent refresh requests; the grace window (SessionConfig.GraceWindow) exists to absorb the common case before this fires.
max_lifetime_exceeded401Session lifetime exceeded, please re-authenticateOnly reachable when SessionConfig.MaxLifetime is set.
invalid_refresh401No refresh token provided
missing_token400Token is required
invalid_input400sessionIds must not be empty / cannot revoke more than 100 sessions at onceFrom bulk session revoke.

Password

CodeStatusMessageHint
reset_token_invalid400Invalid password reset tokenAlso returned if the account behind a previously issued reset token no longer has a password.
reset_token_expired410Password reset token has expired
reset_token_already_used410Password reset token has already been used
wrong_password400Password is incorrect / Current password is incorrectUsed both by delete-account and change-password.
password_update_conflict409Password changed concurrently; retry the operationChange-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_password400No password set. Use set-password instead.For OAuth-only accounts calling change-password instead of set-password.
already_set400User already has a passwordThe reverse case: calling set-password when one already exists.
invalid_code400Invalid set password code
code_used400Set password code has already been used
email_not_configured500Email sender is not configuredAlso returned by forgot-password for every submitted address when no mailer is configured.
email_failed500Failed to send emailUsed by other email flows. Forgot-password logs delivery failures and returns its generic response.

Account (name, deletion, email verification)

CodeStatusMessageHint
password_required400Password is required to delete account
password_account400Use DELETE /auth/account with password to delete your accountAn account with a password tried the code-confirmation delete flow; that flow is for passwordless accounts.
delete_code_invalid400Invalid deletion code
delete_code_expired410Deletion code has expired
delete_code_already_used410Deletion code has already been used
validation_error400Name cannot be empty
code_invalid400Invalid verification code
code_already_used410This code has already been used
code_expired410Verification code has expired
already_verified400Email is already verified
email_not_found200If an account exists, a verification email will be sentPublic 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)

CodeStatusMessage
invite_not_found404Invite not found
invite_expired410This invite has expired
invite_already_used410This invite has already been used
invite_revoked403This invite has been revoked
invite_already_exists409A pending invite for this email already exists
password_mismatch400Passwords do not match
name_required400Name is required

OAuth

CodeStatusMessageHint
provider_not_found404Unrecognized providerThe {provider} path segment does not match any registered WithProvider.
provider_already_linked / already_linked409This provider is already linked to another accountTwo codes for the same condition at different call sites. Treat them the same.
provider_not_linked404This provider is not linked to your account
cannot_unlink_last_provider400Cannot unlink last login method — set a password firstPrevents locking yourself out of an OAuth-only account.
invalid_state400Invalid or expired OAuth stateThe anti-CSRF state token on the callback didn't match.
state_used400OAuth state token already usedState tokens are single-use.
state_expired400OAuth state token has expired
provider_error502Failed to authenticate with providerThe provider's own API rejected the exchange. Not something the caller can fix.
unauthorized401Authentication requiredLink/unlink require an existing session.
email_already_exists409An account with this email already existsLogging 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

CodeStatusMeaning
org_permissions_disabled503The capability is disabled.
org_permissions_not_initialized503The organization owner must initialize authorization.
org_permissions_already_initialized409Initialization already completed.
org_permission_not_found404An installed org permission was not found.
org_permission_exists409The org already has this permission key.
org_permission_in_use409Remove role grants before deleting the business definition.
protected_org_permission403Only an owner can provision library definitions; custom CRUD cannot edit them.
org_role_not_found404The same-org role is absent or unavailable for assignment.
org_role_exists409The org already has this role slug.
org_role_in_use409A member or invitation references the role.
protected_org_role403Built-in org roles cannot be edited or deleted.
org_authorization_conflict412 for If-Match writes; otherwise 409A role/definition/assignment revision changed; refresh the resource before retrying the write.
org_invite_role_changed409The 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:

CodeStatusMessageHint
organizations_disabled503Organizations are not enabledOrganization middleware was requested while organizations are disabled.
org_not_found404Organization not found
org_slug_exists409Organization slug already in use
org_slug_reserved400Organization slug is reserved
org_member_not_found404User is not a member of this organizationAlso 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_exists409User is already a member of this organization
org_member_conflict409Organization membership changed concurrently; retry the operationTwo 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_owner400Cannot remove or demote the last owner of an organization
org_limit_reached400Maximum organization limit reached for userOrganizationConfig.MaxOrgsPerUser.
org_member_limit_reached400Organization member limit reached
org_forbidden403Insufficient organization permissionsReturned by role-gated actions (e.g. a member trying to change another member's role).
no_active_org400Select an active organization firstRequireActiveOrg requires a selected organization in the session.
org_invite_expired400Organization invite link has expired
org_invite_email_mismatch400Authenticated email does not match invite recipientThe invite was sent to a specific address; whoever accepts it must be logged in as that address.
org_metadata_too_large400Organization metadata exceeds 16KB limit
invalid_slug400Slug must be 255 characters or less
invalid_name400Organization name is required
invalid_role400Invalid organization role
invite_not_found404Invite not foundSame 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

CodeStatusMessageHint
already_banned400User is already banned
not_banned400User is not banned
last_admin400Cannot ban / demote / delete the last adminThe library refuses to leave zero admins standing.
invalid_role400Role must be 'user' or 'admin'
name_required400Name is required
session_not_found404Session not foundAdmin revoking a specific user session that does not exist.
user_not_found404User not foundNo 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.
forbidden403You do not have permissionA non-admin hit an admin-only route.
two_factor_required403Sign in again and complete two-factor authenticationThe 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.

CodeStatusCause
csrf_headers_missing403Neither an allowed Origin nor a Referer was sent; both cannot be missing.
csrf_origin_denied403The request's Origin or Referer is not allowed.
csrf_token_missing403The double-submit cookie or header is missing.
csrf_token_invalid403The signature or browser/session binding is invalid.
csrf_token_mismatch403The header does not match the signed cookie.
internal_error500CSRF identity lookup, signing configuration, or token generation failed.
rate_limit_exceeded429The rate limit was exceeded.
rate_limit_error429The 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.

On this page