Authentication
Register, login, logout, identity, password flows, email verification, invites, account deletion, and email 2FA.
Authentication
Routes that hash or verify a password may return 503 password_work_busy when
the shared password-work limit is full. Excess work is rejected without
queuing; retry later with backoff. This includes dummy login checks for unknown
emails and passwordless accounts. See password work limits.
Successful authentication responses use one explicit step; the old requires* flags are removed.
Identity and credential flows. Auth is either Public (no session needed) or Session (the session cookie is required; without it the route answers 401 {"error": ..., "message": ...}). Cookie names below are the defaults (goauth_session, goauth_refresh); see WithBrowser on Configuration. Session credentials travel in cookies, never in bodies: on direct login and register the handler sets both cookies and omits token fields from JSON. User examples show the default disabled app-permissions mode; enabled mode omits role and returns app-role identity/assignment fields. All error responses use the {"error": code, "message": ...} envelope; see Error handling.
POST /auth/register is only mounted when EmailPassword is true. Invite routes are only mounted when InviteSignup is true.
Requests use the app context by default. The admin client sends X-GoAuth-Session: admin on every request, including CSRF bootstrap and 2FA verification. Admin login always selects admin; its default cookies are goauth_admin_session, goauth_admin_refresh, and _goauth_admin_2fa_challenge, with _goauth_admin_csrf for CSRF. Follow-up /auth/me, refresh, and logout requests need the selector too. Register and invite-register reject admin context; use the app registration flow to create an account. See browser configuration.
User examples below show the default mode. With app permissions enabled, user
objects omit the role field and include appRoleId and
appRoleAssignmentRevision; /auth/access describes effective app access.
Shared shapes used below:
{
"step": "authenticated",
"user": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"name": "Ada",
"role": "user",
"isVerified": true,
"isBanned": false,
"twoFactorEnabled": false,
"orgOwnerCount": 0,
"createdAt": "2025-01-01T00:00:00Z",
"updatedAt": "2025-01-01T00:00:00Z"
},
"session": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"userId": "550e8400-e29b-41d4-a716-446655440000",
"ipAddress": "192.168.1.1",
"userAgent": "Mozilla/5.0 ...",
"isRevoked": false,
"expiresAt": "2025-01-31T12:00:00Z",
"refreshExpiresAt": "2025-01-31T12:00:00Z",
"createdAt": "2025-01-01T00:00:00Z",
"lastActiveAt": "2025-01-01T12:00:00Z"
}
}Gated 2FA shape (login, register, invite register when a second factor is owed):
{
"user": { "...": "full user object, same as above" },
"step": "two_factor",
"codeSent": true,
"challengeId": "c4a1...",
"expiresAt": "2026-08-09T12:05:00Z",
"message": "Two-factor code sent to your email"
}Register
POST /auth/register
Creates a user. Auth: Public.
Request body:
{
"email": "newuser@example.com",
"password": "secure-password",
"name": "Ada"
}Response 201 Created: one of three shapes. Email verification pending:
{
"user": { "...": "full user object" },
"step": "verify_email",
"message": "Verification email sent. Please verify your email to continue."
}2FA gated (RequireEmail2FA on): the gated shape above, plus a binding cookie. Complete with POST /auth/2fa/verify. Direct login: both cookies set, body carries the session and omits token fields:
{
"step": "authenticated",
"user": { "...": "full user object" },
"session": { "...": "full session object" }
}Errors: weak_password (400), email_already_exists (409), name_required (blank name).
Login
POST /auth/login
Authenticates with email and password. Auth: Public.
Request body:
{
"email": "user@example.com",
"password": "correct-password"
}Response 200 OK: same three shapes as register. Verification pending:
{
"user": { "...": "full user object" },
"step": "verify_email",
"message": "Please verify your email to continue."
}2FA gated: the gated shape above, plus a binding cookie. Direct login: both cookies set:
{
"step": "authenticated",
"user": { "...": "full user object" },
"session": { "...": "full session object" }
}Errors: invalid_credentials (401) for wrong password, unknown email, or OAuth-only account. All three look identical on purpose.
Logout
POST /auth/logout
Terminates the current session and clears both cookies. Auth: Session.
Response 200 OK:
{ "step": "sign_in", "message": "Logged out" }Errors: session_expired (no usable session).
Get me
GET /auth/me
Returns the caller: full user object, whether the account has a password, and the current session (token hashes are never serialized). Auth: Session. When email verification is required and the account is unverified, the response is restricted instead:
{ "id": "...", "email": "alice@example.com", "isVerified": false, "step": "verify_email" }Show the verification page before protected content. The session is retained;
verification, resend and logout remain available. After code confirmation,
fetch /auth/me again to load the full profile and session.
Response 200 OK:
{
"step": "authenticated",
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"name": "Ada",
"role": "user",
"isVerified": true,
"twoFactorEnabled": false,
"createdAt": "2025-01-01T00:00:00Z",
"updatedAt": "2025-01-01T00:00:00Z",
"hasPassword": true,
"session": { "...": "full session object" }
}Response 401 Unauthorized:
{ "error": "session_expired", "message": "Missing session cookie" }Change name
PUT /auth/name
Changes the caller's display name. Auth: Session.
Request body:
{ "name": "..." }Response 200 OK:
{ "message": "Name updated" }Errors: validation_error (blank name).
Forgot password
POST /auth/forgot-password
Queues the reset request before looking up the account. Unknown, passwordless, and password-bearing accounts receive the same acceptance response; a background worker sends a link only if the account has an existing password. Auth: Public.
Request body:
{ "email": "..." }Response 200 OK:
{ "message": "If an account exists with this email, a reset link will be sent." }Errors: email_not_configured (500, no mailer configured), internal_error (500, queue unavailable). These failures are account-independent. Token storage and delivery failures retry in the background and do not change an accepted request's response.
Reset password
POST /auth/reset-password
Consumes the reset code and sets the new password. Every session for the user is revoked, including any current session. Auth: Public. No email field: the code already identifies the account.
Request body:
{
"code": "...",
"newPassword": "..."
}Response 200 OK:
{ "message": "Password reset successfully" }Errors: reset_token_invalid (including a token for an account that no longer has a password), reset_token_expired (410), reset_token_already_used (410), user_not_found (404, account deleted after the code was issued), password_update_conflict (concurrent use; retry), weak_password.
Change password
POST /auth/change-password
Changes the password for a logged-in user; the old password is required. Keeps the current session, revokes every other one. Auth: Session.
Request body:
{
"oldPassword": "...",
"newPassword": "..."
}Response 200 OK:
{ "message": "Password changed successfully" }Errors: wrong_password, no_password (OAuth-only account; use set-password instead), weak_password.
Request set-password code
POST /auth/set-password/request
Mails a set-password code to an OAuth-only account (one with no password yet). Auth: Session. No body.
Response 200 OK:
{ "message": "If the email exists, a set password link has been sent." }The response's wording says “link”, but the default email template sends an eight-character code. Collect that code and the account's user ID for confirmation; the library does not generate a set-password URL.
Errors: already_set (account already has a password).
Confirm set-password
POST /auth/set-password/confirm
Consumes the set-password code. Auth: Public.
The final transaction checks the exact code and current expiry after hashing
and taking the user and token locks. A code that becomes unusable during that work is
rejected as code_used; the password and code remain unchanged by that attempt.
Request body:
{
"userId": "...",
"code": "...",
"newPassword": "..."
}Response 200 OK:
{ "message": "Password set successfully" }Errors: invalid_code, code_used, reset_token_expired (410), weak_password.
Verify email
POST /auth/verify-email
Verifies the email with the code from the registration or resend email. This does not create a session. It returns step: "authenticated" if the selected context already has this user's live session, otherwise step: "sign_in". Only the code is used. Auth: Public.
Request body:
{ "code": "..." }Response 200 OK:
{
"step": "sign_in",
"user": { "...": "full user object" }
}Errors: code_invalid, code_already_used, code_expired (410), user_not_found (404).
Resend verification
POST /auth/resend-verification
Resends the verification email for the logged-in user. Auth: Session. No body. codeSent is false when a still-valid code was left in place instead of mailing a new one.
Response 200 OK:
{
"codeSent": true,
"expiresAt": "2026-08-09T12:05:00Z",
"message": "Verification email sent"
}Resend verification (public)
POST /auth/verify-email/resend
Public variant for users with no session (e.g. stuck at the verification screen). Queues the request before account lookup and returns the same acceptance response whether the email exists or a code is already live. The worker checks verification state and outstanding-code rules before sending. Auth: Public.
Request body:
{ "email": "..." }Response 200 OK:
{ "message": "If an account exists, a verification email will be sent" }Errors: email_not_configured (500, no mailer configured), internal_error (500, queue unavailable), with the same error for known and unknown addresses. Delivery failures retry in the background.
Delete account
DELETE /auth/account
Deletes the caller's account immediately with password confirmation. Clears both cookies. Auth: Session. OAuth-only accounts (no password) use the code flow below instead.
The verified credential is rechecked inside the deletion transaction. A concurrent password or authentication-state change rejects the stale request.
Request body:
{ "password": "..." }Response 200 OK:
{ "message": "Account deleted successfully" }Errors: wrong_password, invalid_credentials (401, credential or
authentication state changed before deletion), user_banned (403).
Request account deletion code
POST /auth/account/delete/request
Mails a deletion code for accounts without a password. Accounts with a password must use DELETE /auth/account instead. Auth: Session. No body.
Response 200 OK:
{ "message": "Deletion code sent to your email" }Errors: password_account (account has a password; use DELETE /auth/account instead), email_failed (500).
Confirm account deletion
POST /auth/account/delete/confirm
Consumes the deletion code. The user ID comes from the session, never the body. Clears both cookies. Auth: Session.
Request body:
{ "code": "..." }Response 200 OK:
{ "message": "Account deleted successfully" }Errors: delete_code_invalid, delete_code_expired (410), delete_code_already_used (410).
Get invite info
GET /auth/invite/info
Reads an invite code (from the emailed link) and returns who it was issued to. Only mounted when InviteSignup is true. Auth: Public.
Query:
| Param | Type | Required | Notes |
|---|---|---|---|
token | string | Required | The raw invite code from the email link |
Response 200 OK:
{ "email": "invitee@example.com" }Errors: missing_token (no ?token=), invite_not_found (404), invite_already_used (410), invite_revoked (410), invite_expired (410).
Register from invite
POST /auth/invite/register
Completes registration from an invite code, creating the account and a session in one step. Only mounted when InviteSignup is true. Auth: Public.
Request body:
{
"code": "...",
"name": "...",
"password": "...",
"confirmPassword": "..."
}Response 201 Created: both cookies set:
{
"step": "authenticated",
"user": { "...": "full user object" },
"session": { "...": "full session object" }
}With RequireEmail2FA on, the gated 2FA shape is returned instead (see Login).
Errors: password_mismatch (password and confirmPassword differ), name_required, weak_password, invite_not_found, invite_already_used, invite_expired.
Verify 2FA code
POST /auth/2fa/verify
Completes any gated flow (login, admin login, register, invite register). Needs the binding cookie set alongside the challenge, unless challenge binding is disabled. Auth: Public with cookie.
Request body:
{
"challengeId": "...",
"code": "..."
}Response 200 OK: both cookies set:
{
"step": "authenticated",
"user": { "...": "full user object" },
"session": { "...": "full session object" }
}Errors: two_factor_code_invalid (wrong code, unknown challenge, or binding mismatch; all identical on purpose), two_factor_code_already_used, two_factor_code_expired (410).
Resend 2FA code
POST /auth/2fa/resend
Refreshes the code on the same challenge; challengeId does not change. Unknown or mismatched challenge IDs answer the same 200 with challenge_not_found instead, so callers cannot probe for real IDs. Auth: Public with cookie.
Request body:
{ "challengeId": "..." }Response 200 OK:
{
"codeSent": true,
"expiresAt": "2026-08-09T12:05:00Z",
"challengeId": "c4a1...",
"message": "A new code has been sent"
}Confirm an optional 2FA setting by email
POST /auth/2fa/settings/challenge
Requests a confirmation code bound to the current session and the selected
operation. Passwordless accounts can use this without setting a password.
Auth: Session. A globally mandatory 2FA policy rejects personal changes.
{ "action": "enable" }Use "disable" for disabling. Response 200 OK:
{ "challengeId": "...", "codeSent": true, "expiresAt": "2026-11-01T12:05:00Z" }A live challenge is reused with codeSent: false; requesting it again does not
send another email or reset its five-guess budget. It expires after CodeTTL.
Submit its challengeId and code to the matching enable or disable endpoint
using the same authenticated session. This proof cannot complete /auth/2fa/verify
or authorize the opposite setting change.
Enable 2FA
POST /auth/2fa/enable
Opts the caller into 2FA. keepOtherSessions defaults to false (omitted revokes every other session). Auth: Session.
Request body:
{
"password": "...",
"keepOtherSessions": false
}Response 200 OK:
{ "message": "Two-factor authentication enabled" }Alternatively submit { "challengeId": "...", "code": "123456", "keepOtherSessions": false } after requesting an enable confirmation.
Errors: two_factor_code_invalid (invalid, expired, wrong-session or wrong-action proof), two_factor_password_required (no password or confirmation supplied), invalid_credentials (wrong password), two_factor_already_enforced (RequireEmail2FA is on; toggling is rejected).
Disable 2FA
POST /auth/2fa/disable
Opts the caller out. No session revocation. Auth: Session.
Request body:
{ "password": "..." }Response 200 OK:
{ "message": "Two-factor authentication disabled" }Alternatively submit { "challengeId": "...", "code": "123456" } after requesting a disable confirmation. Mandatory global/admin policy cannot be overridden by changing the personal setting.
Errors: two_factor_code_invalid, two_factor_password_required, invalid_credentials, two_factor_already_enforced.
Next
Email changes
When enabled with WithSecurity(SecurityConfig{AllowEmailChange: true}), all four
routes require a session, current verification/MFA policy, CSRF and an allowed
origin. Disabled routes are absent. Inputs are flat JSON objects limited to
4 KiB; duplicate, unknown, case-variant, null and non-string fields are rejected.
POST /auth/email/change/request:{"newEmail":"new@example.com","password":"current password"}. Password is required when the account has an enabled password method. Returns200 {"requestId":"...","expiresAt":"..."}after sending separate proofs to both mailboxes. The old address stays active. Occupancy is checked after proof.POST /auth/email/change/confirm:{"requestId":"...","currentEmailCode":"ABCD2345","newEmailCode":"WXYZ6789"}. Both codes must match the request and initiating session/context. Success returns200 {"step":"sign_in","message":"Email changed. Sign in again."}after committing the verified address, credential revocation and notices.POST /auth/email/change/resend:{"requestId":"...","destination":"current"}. Destination is onlycurrentornew. Returns the same request ID and expiry after rotating that mailbox's code. It does not reset failed guesses.DELETE /auth/email/change/{requestID}: cancels the initiating session's pending request and returns 204. It does not refund the hourly start budget.
See email changes for limits and Go APIs.