Security
SecurityConfig — origin allow-list, CSRF, and password policy — plus every self-service account action: name, password, verification, and deletion.
Security
SecurityConfig is the one required-beyond-the-basics config block — it holds the origin allow-list, the CSRF double-submit cookie, and the password policy every new or changed password is checked against. This page covers that config, then every endpoint an authenticated user calls to manage their own account: changing their name or password, setting a password on an OAuth-only account, the public forgot/reset flow, re-sending email verification, and deleting the account.
For the initial registration verification step (not the resend covered here), see Authentication → Verifying the email. For why login timing is constant and how sessions are hashed, see Security concepts.
Configuration
goauth.WithSecurity(goauth.SecurityConfig{
AllowedOrigins: []string{"https://myapp.com"}, // required — at least one, never "*"
AllowMissingCSRFHeaders: false, // optional, default false
DisableCSRFToken: false, // optional, default false — the token layer is ON
CSRFToken: &middleware.CSRFTokenConfig{ // optional — overrides only; a config is created for you
TokenLength: 32, // optional, default 32
CookieName: "_csrf", // optional, default "_csrf"
HeaderName: "X-CSRF-Token", // optional, default "X-CSRF-Token"
CookiePath: "/", // optional, default "/"
CookieSameSite: http.SameSiteLaxMode, // optional, default Lax
CookieDomain: "", // optional, defaults to CookieConfig.Domain
ExposeCSRFTokenInBody: false, // optional, default false; cross-domain frontends only
// CookieSecure and Secret are filled in automatically — do not set them.
},
PasswordPolicy: domain.PasswordPolicy{ // optional, default {MinLength: 8, RequireDigit: true}
MinLength: 12,
RequireUppercase: true,
RequireDigit: true,
RequireSpecial: false,
},
AllowHTTPURLs: goauth.RequireHTTPSEmailLinks(), // optional, default derived from Environment
})| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
AllowedOrigins | []string | Required | — | At least one entry. "*" is rejected outright — see Authentication → Origin checking for what this actually protects against. |
AllowMissingCSRFHeaders | bool | Optional | false | Allow requests with no Origin/Referer header at all — needed for some native/mobile clients. |
DisableCSRFToken | bool | Optional | false — layer is on | Turns off the double-submit cookie layer entirely. Origin/Referer checking still applies and cannot be disabled. |
CSRFToken | *middleware.CSRFTokenConfig | Optional | auto-created | Overrides only — leaving it nil does not disable anything. The double-submit token sits on top of origin checking, and unlike the origin header it can't be spoofed, since it requires a cookie value only the server issued. Its CookieDomain and ExposeCSRFTokenInBody fields are the ones you set when the frontend is not same-origin with the API — see Deployment. |
PasswordPolicy | domain.PasswordPolicy | Optional | MinLength: 8, RequireDigit: true | Checked on every path that sets a password: register, reset, set-password, and change-password below. |
AllowHTTPURLs | *bool | Optional | derived — true in EnvironmentDev, false otherwise | Governs http:// links in rendered email templates only, not transport. Override with goauth.AllowPlaintextEmailLinks() or goauth.RequireHTTPSEmailLinks(). |
The token has to be readable, not just set
The double-submit check needs the browser to send the _csrf cookie and let JavaScript read it so the value can be echoed in X-CSRF-Token. Same-origin frontends need no additional configuration. A sibling subdomain needs CSRFToken.CookieDomain; a different registrable domain needs CookieSameSite: None and ExposeCSRFTokenInBody because no cookie scope reaches across two sites.
Get this wrong and the failure is distinctive: every read works and every write returns 403. The Deployment guide covers all three cases.
The double-submit token layer is on in every deployment — New creates a CSRFTokenConfig with the defaults shown whenever you leave the field nil, so passing the struct overrides those defaults rather than switching the layer on. Turn it off with DisableCSRFToken: true, which is meant for deployments with no browser clients at all. See Configuration → WithSecurity for the full treatment.
Protecting your own routes with the same check
Auth.RequireCSRF applies this exact double-submit verification — the real HMAC-signed token, not a naive cookie/header string compare — to a route you write and mount yourself:
mux.Handle("POST /widgets", auth.CORS(auth.RequireCSRF(auth.RequireAuth(http.HandlerFunc(createWidget)))))It's a no-op passthrough when DisableCSRFToken: true. The routes mounted by Auth.Mount already have this baked in — reach for RequireCSRF only for routes you register on the mux yourself.
PasswordPolicy (domain.PasswordPolicy) — MinLength int, RequireUppercase bool, RequireDigit bool, RequireSpecial bool. All fields are individually optional, but the struct as a whole replaces the default: setting RequireUppercase: true without repeating MinLength/RequireDigit means those two are no longer enforced. Every violation comes back as the same error code:
| Code | Status | Cause |
|---|---|---|
weak_password | 400 | Too short, too long (over 72 bytes — bcrypt's own limit, not a policy choice), or missing a required character class — the message names exactly what's missing, e.g. Password must be at least 12 characters with an uppercase letter |
Expected WithSecurity errors
allowed_origins must include at least one originallowed_origins must not contain "*" — this disables CSRF protection; list specific origins insteadsecurity: DisableCSRFToken and AllowMissingCSRFHeaders cannot both be set— each is defensible alone, but together a cross-site request carrying neither anOrigin/Refererheader nor a token would pass, leaving no CSRF defense at all. Keep one of the two layers.
SessionConfig.TokenTTL is validated with the session section — see Sessions: session token_ttl must be positive, only reachable if it was explicitly set negative.
Frontend client setup
The Client examples below call the same apiRequest(baseUrl, method, path, body) helper used throughout these guides — see Client → Setup. The named-method wrapper for everything on this page lives on Client → Security.
Change password
Change password — POST /auth/change-password
Requires the current password even though the caller is already authenticated — a stolen but still-valid session cookie shouldn't be enough on its own to take over the account's credentials. On success, every other session is revoked (the one making this request survives), and the CSRF cookie is rotated.
Request body
| Field | Type | Required |
|---|---|---|
oldPassword | string | Required |
newPassword | string | Required — must pass PasswordPolicy |
Response: { "message": "Password changed successfully" }
Errors
| Code | Status | Cause |
|---|---|---|
no_password | 400 | Account has no password yet (OAuth-only) — use Set a password instead |
wrong_password | 400 | oldPassword didn't match |
weak_password | 400 | newPassword fails PasswordPolicy |
user_not_found | 404 | Session's user no longer exists |
internal_error | 500 | Database failure |
curl
curl -X PUT https://api.myapp.com/auth/password \
-H "Content-Type: application/json" \
-H "Origin: https://myapp.com" \
-b cookies.txt \
-d '{"oldPassword":"correct horse battery staple 1","newPassword":"a new stronger passphrase 2"}'Programmatic (Go)
err := auth.Services.Password.ChangePassword(ctx, goauth.ChangePasswordInput{
UserID: userID,
OldPassword: "correct horse battery staple 1",
NewPassword: "a new stronger passphrase 2",
ExceptSessionID: currentSession.ID, // keep this session alive; omit to revoke every session including this one
})Client
await apiRequest(API_BASE, "PUT", "/auth/password", {
oldPassword: "correct horse battery staple 1",
newPassword: "a new stronger passphrase 2",
});See Client → Change password for the wrapper and error handling.
Set a password (OAuth-only accounts)
Two-step, code-based flow for an account that signed up via OAuth and has no password yet — ChangePassword above doesn't apply since there's no old password to confirm.
Request a set-password code — POST /auth/set-password/request
Auth required. Emails an 8-character code, valid for 10 minutes. Always returns the same generic success message.
Errors
| Code | Status | Cause |
|---|---|---|
already_set | 400 | Account already has a password — use Change password instead |
user_not_found | 404 | Session's user no longer exists |
curl
curl -X POST https://api.myapp.com/auth/set-password/request \
-H "Origin: https://myapp.com" \
-b cookies.txtProgrammatic (Go)
err := auth.Services.Password.RequestSetPassword(ctx, userID)Client
await apiRequest(API_BASE, "POST", "/auth/set-password/request");See Client → Set password for the wrapper and two-step flow.
Confirm a set-password code — POST /auth/set-password/confirm
Public by design — userId comes from the request body, not a session, since the whole point is to let a user finish this from an email link without necessarily being logged in on that device.
Request body: { "userId", "code", "newPassword" }
Response: { "message": "Password set successfully" }
Errors
| Code | Status | Cause |
|---|---|---|
already_set | 400 | Account already has a password |
invalid_code | 400 | Code doesn't match, is the wrong type, or userId doesn't match the token's user |
code_used | 400 | Code was already redeemed |
reset_token_expired | 410 | Past the 10-minute window |
weak_password | 400 | Fails PasswordPolicy |
curl
curl -X POST https://api.myapp.com/auth/set-password/confirm \
-H "Content-Type: application/json" \
-H "Origin: https://myapp.com" \
-d '{"userId":"b3f1...","code":"ABCD1234","newPassword":"a new strong passphrase 2"}'Programmatic (Go)
err := auth.Services.Password.ConfirmSetPassword(ctx, goauth.ConfirmSetPasswordInput{
UserID: "b3f1...",
Code: "ABCD1234",
NewPassword: "a new strong passphrase 2",
})Client
await apiRequest(API_BASE, "POST", "/auth/set-password/confirm", { userId, code, newPassword });See Client → Set password for the wrapper and two-step flow.
Forgot / reset password
Public flow for a user who's locked out entirely — no session involved on either end.
Request a reset — POST /auth/forgot-password
Request body: { "email": "..." }. Always returns the same generic success message, whether or not the account exists.
Timing-safe on purpose
When the email doesn't match any account, the handler still runs two dummy token-generation calls before returning — so the response takes roughly the same time either way. Without that, an attacker could tell which emails are registered just by measuring response latency.
curl
curl -X POST https://api.myapp.com/auth/forgot-password \
-H "Content-Type: application/json" \
-H "Origin: https://myapp.com" \
-d '{"email":"ada@example.com"}'Programmatic (Go)
err := auth.Services.Password.ForgotPassword(ctx, goauth.ForgotPasswordInput{Email: "ada@example.com"})Client
await apiRequest(API_BASE, "POST", "/auth/forgot-password", { email });See Client → Forgot / reset password for the wrapper and two-step flow.
Complete the reset — POST /auth/reset-password
Requesting a new reset invalidates any earlier unused one for the same user — only the most recent link works. On success, every session for that user is revoked (there's no "except current" here, since there's no session to except), and the CSRF cookie is rotated.
Request body: { "code", "newPassword" }
Response: { "message": "Password reset successfully" }
Errors
| Code | Status | Cause |
|---|---|---|
reset_token_invalid | 400 | Code doesn't match, or matches a token of the wrong type |
reset_token_already_used | 410 | Already redeemed |
reset_token_expired | 410 | Past SessionConfig.TokenTTL (default 1h) |
weak_password | 400 | Fails PasswordPolicy |
user_not_found | 404 | The user behind the token no longer exists |
curl
curl -X POST https://api.myapp.com/auth/reset-password \
-H "Content-Type: application/json" \
-H "Origin: https://myapp.com" \
-d '{"code":"the-code-from-the-email","newPassword":"a new strong passphrase 2"}'Programmatic (Go)
err := auth.Services.Password.ResetPassword(ctx, goauth.ResetPasswordInput{
Code: "the-code-from-the-email",
NewPassword: "a new strong passphrase 2",
})Client
await apiRequest(API_BASE, "POST", "/auth/reset-password", { code, newPassword });See Client → Forgot / reset password for the wrapper and two-step flow.
Verify / resend verification
Covers re-sending the verification email after the fact — for the initial code sent at registration, see Authentication → Verifying the email. Both routes below hit the same underlying SendVerification, they just differ in how the target user is identified.
Resend while logged in — POST /auth/resend-verification
Auth required, no body — resends to the authenticated user's own email.
A success response does not mean an email was sent. When a usable code is still outstanding it is left in place rather than a second one mailed, and when the last one was minted inside VerificationResendInterval the refresh is throttled. Both cases return 200 with codeSent: false:
{
"codeSent": false,
"expiresAt": "2026-01-01T12:15:00Z",
"message": "A verification code was already sent, check your email"
}Branch on codeSent, not on the status code — it is the same flag POST /auth/login returns for a reused 2FA challenge, and the only thing separating a deliberate skip from a mailer that is quietly failing. A mailer that actually fails returns email_failed (500) and leaves no token behind, so codeSent: false always refers to a code the mailer accepted — retrying straight after a failure mails a new one rather than reporting the undelivered code as outstanding.
Errors
| Code | Status | Cause |
|---|---|---|
user_not_found | 404 | Session's user no longer exists |
already_verified | 400 | Email is already verified |
email_failed | 500 | The mailer rejected the send |
curl
curl -X POST https://api.myapp.com/auth/resend-verification \
-H "Origin: https://myapp.com" \
-b cookies.txtProgrammatic (Go)
result, err := auth.Services.Verify.ResendVerification(ctx, userID)
if err == nil && !result.Sent {
// A usable code was already outstanding; result.ExpiresAt is its expiry.
}Client
await apiRequest(API_BASE, "POST", "/auth/resend-verification");See Client → Resend verification for the wrapper and both logged-in and public variants.
Resend by email — POST /auth/verify-email/resend
Public — for a user who isn't logged in on this device (e.g. finishing signup on a different browser). Request body: { "email": "..." }. Always returns the same generic success message — an unknown email and an already-verified one look identical, to avoid leaking which addresses are registered.
Note this route deliberately omits the codeSent field the authenticated one returns. Reporting it here would answer "does an unverified account exist for this address, and does it already have a live code?" to anyone who asks, which is precisely what the flat response withholds.
curl
curl -X POST https://api.myapp.com/auth/verify-email/resend \
-H "Content-Type: application/json" \
-H "Origin: https://myapp.com" \
-d '{"email":"ada@example.com"}'Programmatic (Go)
auth.Services.Verify.SendVerificationByEmail(ctx, "ada@example.com") // error wraps a *domain.AuthError, deliberately not surfaced to the caller by the handlerClient
await apiRequest(API_BASE, "POST", "/auth/verify-email/resend", { email });See Client → Resend verification for the wrapper and both logged-in and public variants.
Next
- User management — name changes, 2FA toggle, and account deletion
- Authentication — register, login, logout, and the initial verification step
- Admin — banning, role changes, and admin-initiated session revocation
- Configuration — every other config block