Authentication
Registering, logging in, and logging out — configuration, request/response shapes, and every way to call it: curl, Go, and a browser client.
Authentication
For one executable end-to-end path, start with First working application. This guide documents individual operations.
This page walks through the three entry points to a session — register, login, logout — end to end: which config flags turn each path on, exactly what each endpoint takes and returns, every error it can produce, and three ways to call it: curl, directly in Go via Auth.Services(), and from a browser client.
For the full path/param reference across every route (not just these three), see Routes. For the error envelope shape and codes shared across the whole API, see Error Handling.
The curl mutation examples use jq to read the mandatory CSRF bootstrap JSON. Keep the same cookie jar for bootstrap and the following request; the bootstrap is repeated after authentication changes.
These examples use the default app context. For an admin dashboard, send X-GoAuth-Session: admin on every request, including bootstrap and 2FA follow-ups; use the admin client wrapper. App and admin login/logout use independent cookie sets even on the same API host.
Configuration
AuthenticationConfig selects allowed login and credential methods.
RegistrationConfig selects public/invitation account creation and the global
email-verification requirement. Signup is closed by default; this example opens
password signup and requires verification.
goauth.WithAuthentication(goauth.AuthenticationConfig{
EmailPassword: true, // default
OAuth: false, // enable explicitly with a configured provider
}),
goauth.WithRegistration(goauth.RegistrationConfig{
AllowPublic: true, // default false; open signup explicitly
EmailPassword: true, // default false
OAuth: false,
InviteSignup: false,
RequireEmailVerification: true, // default false
InviteTTL: 7 * 24 * time.Hour,
VerificationCodeTTL: 15 * time.Minute,
VerificationResendInterval: time.Minute,
})Authentication and signup are separate
Enable AuthenticationConfig.OAuth and configure at least one WithProvider
for provider login. Enable registration OAuth only when new provider accounts
may be created. Existing accounts can log in with public signup closed.
Full field reference, defaults, and validation errors live on Configuration — this page only recaps the fields that change how register/login/logout behave.
Frontend client setup
The Client examples below call a small apiRequest(baseUrl, method, path, body) helper — cookies-included fetch plus CSRF header handling. It's defined once on the Client → Setup page rather than repeated in every section here; see that page for what it does and why. A named-method wrapper built on top of it (authApi.login(...), etc.) lives on Client → Authentication.
Origin checking
Routes protected by middleware.OriginCheck check state-changing requests (POST/PUT/PATCH/DELETE) before they reach a handler. A request with neither an Origin nor a Referer header gets a JSON 403 with error code csrf_headers_missing — which is why every curl example on this page below sets -H "Origin: https://myapp.com". Without it, they 403 against any real deployment.
This is a plausibility check, not proof the request came from a browser
Origin is a browser signal, not a credential: a command-line client can send the same header. It protects a victim's browser from cross-site requests; it does not restrict direct API clients. CSRF tokens, rate limits, and application rules enforce the remaining controls.
Register
Three ways to create an account, gated by the flags above:
- Email/password (
EmailPassword) — immediate session, or a verification step first ifRequireEmailVerificationis on. - Invite (
InviteSignup) — the account is created pre-verified; there's no separate verification step. - OAuth — covered on the Providers pages, not here, since the flow is a browser redirect rather than a JSON request/response.
Register with email/password — POST /auth/register
Requires authentication and registration EmailPassword: true, plus AllowPublic: true.
Request body
| Field | Type | Required |
|---|---|---|
email | string | Required |
password | string | Required — must pass SecurityConfig.PasswordPolicy |
name | string | Required — rejected as name_required if blank after trimming |
Response — RequireEmailVerification: false (201 Created) — a session is issued immediately:
{
"step": "authenticated",
"user": {
"id": "b3f1...",
"email": "ada@example.com",
"name": "Ada Lovelace",
"role": "user",
"isVerified": false,
"isBanned": false,
"orgOwnerCount": 0,
"createdAt": "2026-08-09T12:00:00Z",
"updatedAt": "2026-08-09T12:00:00Z"
},
"session": {
"id": "9e2c...",
"userId": "b3f1...",
"ipAddress": "203.0.113.4",
"userAgent": "Mozilla/5.0 ...",
"isRevoked": false,
"expiresAt": "2026-09-08T12:00:00Z",
"refreshExpiresAt": "2026-09-08T12:00:00Z",
"createdAt": "2026-08-09T12:00:00Z"
}
}The raw session and refresh tokens are never in this body — they're set as HttpOnly cookies (goauth_session, goauth_refresh by default) on the same response, and the CSRF cookie is rotated at the same time. isVerified is false here even though no verification was required — that flag only ever flips to true through the verification flow itself.
Response — RequireEmailVerification: true (201 Created) — no session yet:
{
"user": { "...": "same shape as above, isVerified: false" },
"step": "verify_email",
"message": "Verification email sent. Please verify your email to continue."
}No cookies are set on this response. The account exists in the database, but there's nothing to authenticate with yet — the client has to go through Verifying the email below.
Response — TwoFactorConfig.RequireEmail2FA: true (201 Created) — a session is not issued; register reaches this gate only when RequireEmailVerification is off; otherwise it returns the verification response above (see Two-factor authentication):
{
"user": { "...": "same shape as above" },
"step": "two_factor",
"codeSent": true,
"challengeId": "c4a1...",
"expiresAt": "2026-08-09T12:05:00Z",
"message": "Two-factor code sent to your email"
}Alongside this body, a binding cookie is set (_2fa_challenge by default) — see Two-factor authentication for what it's for and how POST /auth/2fa/verify uses it.
Errors
| Code | Status | Cause |
|---|---|---|
invalid_json | 400 | Malformed request body |
name_required | 400 | name was empty after trimming |
weak_password | 400 | Fails PasswordPolicy — the message names exactly what's missing, e.g. Password must be at least 8 characters with an uppercase letter |
email_already_exists | 409 | An account with that email already exists — including the loser of a race between two concurrent registrations for the same email |
method_disabled | 405 | EmailPassword is false |
forbidden | 403 | AllowPublic is false — registration is invite-only |
internal_error | 500 | Hashing or database failure |
curl
CSRF=$(curl -fsS https://api.myapp.com/auth/csrf-token -b cookies.txt -c cookies.txt | jq -r .token)
curl -X POST https://api.myapp.com/auth/register \
-H "X-CSRF-Token: $CSRF" \
-b cookies.txt \
-H "Content-Type: application/json" \
-H "Origin: https://myapp.com" \
-c cookies.txt \
-d '{"email":"ada@example.com","password":"correct horse battery staple 1","name":"Ada Lovelace"}'-c cookies.txt saves the Set-Cookie headers so later requests (curl's -b cookies.txt) reuse the session — curl doesn't do this automatically the way a browser does. The Origin header is required too — see Origin checking above.
Programmatic (Go)
Go examples on this page import github.com/nazimdjebloun/go-auth/api for operation inputs and results.
Calling the service directly skips HTTP entirely — no cookies get set for you, so you own the raw tokens from here.
result, err := auth.Services().Auth.Register(ctx, api.RegisterInput{
Email: "ada@example.com",
Password: "correct horse battery staple 1",
Name: "Ada Lovelace",
IP: r.RemoteAddr, // optional — only used for the audit event and session device metadata
UserAgent: r.UserAgent(), // optional — same
})
if err != nil {
// err is always a *domain.AuthError under the hood — extract it with
// errors.As(err, &authErr) for authErr.Code/.Message, or match a
// specific one with errors.Is(err, domain.ErrEmailAlreadyExists)
// — see Error Handling → Handling errors programmatically.
}
if result.RequiresVerification {
// result.Session, result.SessionToken, result.RefreshToken are all zero-valued here
} else if result.RequiresTwoFactor {
// no session issued yet — result.TwoFactorChallenge is the challenge id,
// result.BindingToken() is what the handler would set as a cookie —
// same shape as the Login gated response below
} else {
// result.SessionToken / result.RefreshToken are the raw values —
// set them as cookies yourself, or hand them to your own token transport
}Client
const result = await apiRequest(API_BASE, "POST", "/auth/register", {
email: "ada@example.com",
password: "correct horse battery staple 1",
name: "Ada Lovelace",
});
if (result.step === "verify_email") {
// show "check your email" — no session exists yet
} else if (result.step === "two_factor") {
setChallengeId(result.challengeId); // show the code screen; no session exists yet
} else {
setUser(result.user); // cookies are already set by the browser
}For a complete register form with error handling, see Client → Register.
Verifying the email
Only relevant when RequireEmailVerification: true. Two ways in: a link with a code (POST /auth/verify-email), or a resend if the email never arrived.
A mailer is required either way
Email verification requires WithMailer or WithEmail. Admin login also requires a mailer while its default 2FA challenge is enabled. In EnvironmentDev, a required but unconfigured mailer defaults to log-only delivery. See Configuration.
POST /auth/verify-email — body { "code": "..." }. On success, the handler marks the email verified. It issues no session cookies. Continue according to step: authenticated resumes this account's existing live session; sign_in requires login.
{
"step": "sign_in",
"user": { "...": "isVerified is now true" }
}| Code | Status | Cause |
|---|---|---|
code_invalid | 400 | Code doesn't match any token, or matches one of the wrong type |
code_already_used | 410 | Code was already redeemed |
code_expired | 410 | Past VerificationCodeTTL |
user_not_found | 404 | The user behind the token no longer exists |
internal_error | 500 | Database failure while marking the user verified or the token used |
POST /auth/verify-email/resend — body { "email": "..." }. Queues the request before account lookup and returns a generic acceptance message whether or not the account exists. Queue failures return internal_error (500) for every address. Delivery happens in the background; authenticated resend remains synchronous and returns codeSent.
curl
CSRF=$(curl -fsS https://api.myapp.com/auth/csrf-token -b cookies.txt -c cookies.txt | jq -r .token)
curl -X POST https://api.myapp.com/auth/verify-email \
-H "X-CSRF-Token: $CSRF" \
-b cookies.txt \
-H "Content-Type: application/json" \
-H "Origin: https://myapp.com" \
-c cookies.txt \
-d '{"code":"the-code-from-the-email"}'Programmatic (Go)
The service call verifies the user without creating a session, just like the HTTP handler. Ask the user to sign in afterward so password authentication and any required second factor run before session issuance:
user, err := auth.Services().Verify.VerifyEmail(ctx, code)
if err != nil {
// wraps a *domain.AuthError — see Error Handling → Handling errors programmatically
return
}
// user.IsVerified is now true; continue through Auth.Login, including its
// RequiresVerification / RequiresTwoFactor branches, before setting session cookies.Client
const result = await apiRequest(API_BASE, "POST", "/auth/verify-email", { code });
// result.user.isVerified is now true; resume on authenticated, otherwise show sign-inFor a complete email verification form with resend, see Client → Email verification.
Register via invite — requires InviteSignup: true
Two-step: look up the invite to pre-fill the form, then complete registration with it. Invite accounts are created pre-verified — there's no separate email-verification step.
GET /auth/invite/info?token=... — public lookup, used to show "you're signing up as ada@example.com" before the user types a password.
{ "email": "ada@example.com" }| Code | Status | Cause |
|---|---|---|
missing_token | 400 | token query param omitted |
invite_not_found | 404 | Token doesn't match any invite |
invite_already_used | 410 | Invite was already accepted |
invite_revoked | 403 | Invite was revoked by an admin |
POST /auth/invite/register — body { "code", "name", "password", "confirmPassword" }. code is the same raw token as token above, just renamed in the body.
{
"step": "authenticated",
"user": { "...": "isVerified: true" },
"session": { "...": "same shape as register" }
}| Code | Status | Cause |
|---|---|---|
method_disabled | 405 | InviteSignup is false |
invite_not_found | 404 | Bad code |
invite_already_used / invite_expired | 410 | Invite no longer usable — a revoked invite also surfaces as invite_already_used |
name_required | 400 | Blank name |
password_mismatch | 400 | password doesn't match confirmPassword |
weak_password | 400 | Fails PasswordPolicy |
curl
# 1. Look up the invite
curl "https://api.myapp.com/auth/invite/info?token=THE_RAW_CODE"
# 2. Complete registration
CSRF=$(curl -fsS https://api.myapp.com/auth/csrf-token -b cookies.txt -c cookies.txt | jq -r .token)
curl -X POST https://api.myapp.com/auth/invite/register \
-H "X-CSRF-Token: $CSRF" \
-b cookies.txt \
-H "Content-Type: application/json" \
-H "Origin: https://myapp.com" \
-c cookies.txt \
-d '{"code":"THE_RAW_CODE","name":"Ada Lovelace","password":"correct horse battery staple 1","confirmPassword":"correct horse battery staple 1"}'Programmatic (Go)
invite, err := auth.Services().Invite.GetInviteByToken(ctx, rawToken)
// invite.Email — pre-fill the form with this
result, err := auth.Services().Invite.CompleteInviteRegistration(ctx, api.CompleteInviteInput{
Code: rawToken,
Name: "Ada Lovelace",
Password: "correct horse battery staple 1",
ConfirmPassword: "correct horse battery staple 1",
IP: r.RemoteAddr,
UserAgent: r.UserAgent(),
})Client
const { email } = await apiRequest(
API_BASE, "GET", `/auth/invite/info?token=${encodeURIComponent(token)}`
);
const result = await apiRequest(API_BASE, "POST", "/auth/invite/register", {
code: token,
name: "Ada Lovelace",
password,
confirmPassword,
});For a complete invite registration form, see Client → Invite registration.
Login
Login — POST /auth/login
Registration method switches do not disable existing-account login.
AuthenticationConfig.EmailPassword must be enabled for password login, and
RequireEmailVerification still gates unverified accounts.
Request body
| Field | Type | Required |
|---|---|---|
email | string | Required |
password | string | Required |
Response — normal success (200 OK) — identical shape to register's success response: { "user", "session" }, cookies set the same way.
Response — verification pending (200 OK) — if RequireEmailVerification: true and the account hasn't verified yet:
{
"user": { "...": "isVerified: false" },
"step": "verify_email",
"message": "Please verify your email to continue."
}No session is issued — the credentials were correct, but that's not enough on its own.
Response — two-factor pending (200 OK) — if TwoFactorConfig.RequireEmail2FA: true, or the account has per-user 2FA on (User.TwoFactorEnabled):
{
"user": { "...": "same shape as normal success" },
"step": "two_factor",
"codeSent": true,
"challengeId": "c4a1...",
"expiresAt": "2026-08-09T12:05:00Z",
"message": "Two-factor code sent to your email"
}Same shape and same binding cookie as the register case above — see Two-factor authentication to complete the flow. codeSent is false and the message changes to "A two-factor code was already sent, check your email" if the client calls Login again while a still-valid, uncapped challenge already exists — the code isn't re-mailed on every login attempt.
codeSent: false always refers to a code the mailer accepted. A login whose send fails returns email_failed (500) and leaves no challenge behind, so the next attempt mints and mails a fresh one rather than pointing at a code that was never delivered. POST /auth/2fa/resend is the exception: it refreshes the code on the existing challenge row, and a failed send there keeps the row — that row carries the lineage's attempt count, which a delete would reset. Retrying the resend re-mints on it while refreshes remain.
Errors
| Code | Status | Cause |
|---|---|---|
invalid_json | 400 | Malformed body |
invalid_credentials | 401 | Wrong email/password, unknown email, or an account with no password (OAuth-only) — all three look identical on purpose |
user_banned | 403 | Account is banned |
internal_error | 500 | Session creation failure |
invalid_credentials for an email that doesn't exist at all runs comparisons against a startup-generated dummy hash in the currently configured hasher's format, so that request burns the same KDF work as a wrong-password one — see Security for why that matters.
curl
CSRF=$(curl -fsS https://api.myapp.com/auth/csrf-token -b cookies.txt -c cookies.txt | jq -r .token)
curl -X POST https://api.myapp.com/auth/login \
-H "X-CSRF-Token: $CSRF" \
-b cookies.txt \
-H "Content-Type: application/json" \
-H "Origin: https://myapp.com" \
-c cookies.txt \
-d '{"email":"ada@example.com","password":"correct horse battery staple 1"}'Programmatic (Go)
result, err := auth.Services().Auth.Login(ctx, api.LoginInput{
Email: "ada@example.com",
Password: "correct horse battery staple 1",
IP: r.RemoteAddr,
UserAgent: r.UserAgent(),
})
if err != nil {
// wraps a *domain.AuthError — see Error Handling → Handling errors programmatically
}
if result.RequiresVerification {
// no session issued
}
if result.RequiresTwoFactor {
// no session issued — result.TwoFactorChallenge is the challenge id,
// result.BindingToken() is what the handler would set as a cookie
}Client
const result = await apiRequest(API_BASE, "POST", "/auth/login", { email, password });
if (result.step === "verify_email") {
// route to a "verify your email" screen
} else if (result.step === "two_factor") {
// route to a "enter your code" screen — the binding cookie is already set
setChallengeId(result.challengeId);
} else {
setUser(result.user); // cookies are already set
}For a complete login form with branching logic and error handling, see Client → Login.
Admin login has its own flow
POST /auth/admin/login is documented in Admin. It requires a second factor by default, independent of other 2FA settings.
Two-factor authentication
Turned on globally with TwoFactorConfig.RequireEmail2FA (mandatory for password and OAuth authentication, with email verification taking precedence when pending) or per-user via POST /auth/2fa/enable (opt-in, unless RequireEmail2FA is on, in which case per-user toggling is rejected). Password login applies either requirement. Password and invite registration gate only on the global flag, with email verification taking precedence for password registration. When gated, these paths return the {user, step: "two_factor", codeSent, challengeId, expiresAt, message} shape shown above instead of a session, plus a binding cookie. OAuth applies the same global and per-account 2FA requirements; see the authentication policy matrix. Full config reference on Configuration; the risk trade-offs behind this design are on Security.
Requires a mailer
Same requirement as email verification — TwoFactorConfig.RequireEmail2FA or TwoFactorConfig.DefaultEnabled without WithMailer/WithEmail is rejected outright by NewConfig.
Verifying the code — POST /auth/2fa/verify
Completes any of the gated flows above — it doesn't need to know which one started the challenge. Body: { "challengeId": "...", "code": "..." }. The binding cookie set alongside the gated response must be present on this request; a missing or mismatched cookie is rejected the same way a wrong code is, without revealing which.
{
"step": "authenticated",
"user": { "...": "same shape as login" },
"session": { "...": "same shape as login" }
}Session and refresh cookies are set here, not on the original gated response — until this call succeeds, nothing has been issued.
| Code | Status | Cause |
|---|---|---|
two_factor_code_invalid | 400 | Wrong code, wrong/missing binding cookie, or the lineage already hit its 5-guess cap — all three look identical on purpose |
two_factor_code_expired | 410 | Past TwoFactorConfig.CodeTTL (default 5m) |
two_factor_code_already_used | 410 | This challenge already completed a login |
curl
CSRF=$(curl -fsS https://api.myapp.com/auth/csrf-token -b cookies.txt -c cookies.txt | jq -r .token)
curl -X POST https://api.myapp.com/auth/2fa/verify \
-H "X-CSRF-Token: $CSRF" \
-H "Content-Type: application/json" \
-H "Origin: https://myapp.com" \
-b cookies.txt -c cookies.txt \
-d '{"challengeId":"c4a1...","code":"482913"}'-b cookies.txt matters here — it's what sends the binding cookie saved from the login/register response back to the server.
Programmatic (Go)
result, err := auth.Services().TwoFactor.Verify(ctx, api.TwoFactorVerifyInput{
ChallengeID: challengeID, BindingToken: bindingToken, Code: code,
IP: r.RemoteAddr, UserAgent: r.UserAgent(),
})
if err != nil {
// wraps a *domain.AuthError — see Error Handling → Handling errors programmatically
}
// result.User is the authenticated user; result.SessionToken / result.RefreshToken
// are the raw values — set them as cookies yourselfClient
const result = await apiRequest(API_BASE, "POST", "/auth/2fa/verify", {
challengeId,
code,
});
setUser(result.user); // cookies are already setFor a complete two-factor form with resend, see Client → Two-factor.
Resending the code — POST /auth/2fa/resend
Body: { "challengeId": "..." }. Refreshes the code on the same challenge — challengeId doesn't change, so the client doesn't need to update it. Capped at 3 resends per challenge; combined with the 5-guess attempt cap, this is what keeps the guess budget bounded rather than resettable on demand.
An unknown or binding-mismatched challengeId gets the same generic 200 as a real resend (challenge_not_found, "If the challenge is valid, a new code has been sent") — the endpoint never confirms or denies which challenge IDs are real.
curl
CSRF=$(curl -fsS https://api.myapp.com/auth/csrf-token -b cookies.txt -c cookies.txt | jq -r .token)
curl -X POST https://api.myapp.com/auth/2fa/resend \
-H "X-CSRF-Token: $CSRF" \
-H "Content-Type: application/json" \
-H "Origin: https://myapp.com" \
-b cookies.txt -c cookies.txt \
-d '{"challengeId":"c4a1..."}'Programmatic (Go)
result, err := auth.Services().TwoFactor.Resend(ctx, api.TwoFactorResendInput{ChallengeID: challengeID, BindingToken: bindingToken})Client
await apiRequest(API_BASE, "POST", "/auth/2fa/resend", { challengeId });For a complete two-factor form with resend, see Client → Two-factor.
For enabling and disabling 2FA, see User management → Two-factor.
Logout
Logout — POST /auth/logout
Takes no body and requires an authenticated session. It revokes that session server-side, then clears both session cookies and rotates the CSRF cookie. If server-side revocation fails, it returns 500 internal_error and does not clear the cookies; the client must not discard its local authenticated state or redirect as though logout succeeded.
{ "step": "sign_in", "message": "Logged out" }curl
CSRF=$(curl -fsS https://api.myapp.com/auth/csrf-token -b cookies.txt -c cookies.txt | jq -r .token)
curl -X POST https://api.myapp.com/auth/logout \
-H "X-CSRF-Token: $CSRF" \
-H "Origin: https://myapp.com" \
-b cookies.txt -c cookies.txtProgrammatic (Go)
The HTTP handler revokes by the raw session token — the same value stored in the cookie — not by session ID:
err := auth.Services().Session.Revoke(ctx, sessionToken) // sessionToken is the raw value from the cookie, not the session's IDIf you only have the session ID instead (e.g. an admin ending one specific session — see Routes → Sessions), use auth.Services().Auth.Logout(ctx, sessionID) instead — same effect, looked up differently.
Client
await apiRequest(API_BASE, "POST", "/auth/logout");
setUser(null);
window.location.href = "/login";For a complete logout button with error handling, see Client → Logout.
For account deletion and name changes, see User management.
Next
- User management — name changes, 2FA toggle, and account deletion
- Security — password changes and verification resend
- Admin — admin login and platform-wide user management
- Routes — every other endpoint, including sessions, password, and account management
- Error Handling — the full error code list
- Providers — OAuth setup for GitHub and Google
Email verification with an existing session
RequireEmailVerification is a global access requirement. Enabling it later
restricts existing unverified accounts on their next protected request.
GET /auth/me returns only the account ID, email, isVerified: false, and
step: "verify_email"; render the same verification screen used after
signup. Send a code on an explicit user action, not every page load. Verification,
resend and logout remain available. After successful verification, the retained
session resumes protected access without another login. Signup that never
received a session still signs in after verification. Disabling the requirement
restores access for unverified sessions too. Refresh cannot bypass the gate.