go-auth

Error Handling

Every error the HTTP API returns, grouped by area, with its code, HTTP status, and message.

Error Handling

Most endpoints 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.

Handling errors programmatically

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. Library errors 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, goauth.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.

Not every layer uses this shape

Two paths do not use the JSON envelope:

  • CSRF origin/token middleware (middleware.OriginCheck, middleware.CSRFToken) responds with a plain-text body via http.Error, e.g. Forbidden - CSRF headers missing (403), not JSON. It runs ahead of your handlers and has no domain error to format.
  • OAuth callback errors are never returned as a JSON body at all. The browser is mid-redirect at that point. They come back as a redirect to {BaseURL}/auth/callback?error={code}&provider={provider}, and your frontend reads error from the query string.

Organization membership middleware (middleware.RequireOrgMember, RequireOrgRole) uses the normal envelope. A non-member receives org_member_not_found (404); an under-privileged member receives org_forbidden (403).

All remaining service and handler errors use the {"error", "message"} envelope.

General

CodeStatusMessage
internal_error500Internal server error
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
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.

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.
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 token
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 configuredOnly reachable on the confirm-delete-account flow, which requires a mailer explicitly.
email_failed500Failed to send reset email / Failed to send emailThe token was created but delivery failed at the transport level (SMTP error, provider API error).

Account (name, deletion, email verification)

CodeStatusMessageHint
password_required400Password is required to delete account
password_account400Use DELETE /auth/account with password to delete your accountAn OAuth-only account tried the code-confirmation delete flow instead.
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 has been sentHTTP 200 on purpose: resend-verification never reveals whether the email exists, so a non-existent address gets the same success response as a real one.

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 Not every layer uses this shape above.

Organizations

CodeStatusMessageHint
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).
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 Not every layer uses this shape 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.
forbidden403You do not have permissionFrom RequireRole(domain.RoleAdmin): a non-admin hit an admin-only route.

CSRF and rate limiting

These respond outside the JSON envelope or with a narrower one. See Not every layer uses this shape for the CSRF middleware specifics.

WhereStatusBody
Missing Origin and Referer, AllowMissingCSRFHeaders: false403Forbidden - CSRF headers missing (plain text)
Origin/Referer present but not allowed403Forbidden (plain text)
Double-submit cookie missing, invalid, or mismatched403Forbidden - CSRF token missing / invalid / mismatch (plain text)
Rate limit exceeded429{"error": "rate_limit_exceeded", "message": "Too many requests, please try again later"} (JSON, uses the normal envelope)

On this page