go-auth
Guides

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
})
FieldTypeRequiredDefaultNotes
AllowedOrigins[]stringRequiredAt least one entry. "*" is rejected outright — see Authentication → Origin checking for what this actually protects against.
AllowMissingCSRFHeadersboolOptionalfalseAllow requests with no Origin/Referer header at all — needed for some native/mobile clients.
DisableCSRFTokenboolOptionalfalse — layer is onTurns off the double-submit cookie layer entirely. Origin/Referer checking still applies and cannot be disabled.
CSRFToken*middleware.CSRFTokenConfigOptionalauto-createdOverrides 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.
PasswordPolicydomain.PasswordPolicyOptionalMinLength: 8, RequireDigit: trueChecked on every path that sets a password: register, reset, set-password, and change-password below.
AllowHTTPURLs*boolOptionalderived — true in EnvironmentDev, false otherwiseGoverns 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 deploymentNew 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:

CodeStatusCause
weak_password400Too 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 origin
  • allowed_origins must not contain "*" — this disables CSRF protection; list specific origins instead
  • security: DisableCSRFToken and AllowMissingCSRFHeaders cannot both be set — each is defensible alone, but together a cross-site request carrying neither an Origin/Referer header 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

FieldTypeRequired
oldPasswordstringRequired
newPasswordstringRequired — must pass PasswordPolicy

Response: { "message": "Password changed successfully" }

Errors

CodeStatusCause
no_password400Account has no password yet (OAuth-only) — use Set a password instead
wrong_password400oldPassword didn't match
weak_password400newPassword fails PasswordPolicy
user_not_found404Session's user no longer exists
internal_error500Database 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

CodeStatusCause
already_set400Account already has a password — use Change password instead
user_not_found404Session's user no longer exists

curl

curl -X POST https://api.myapp.com/auth/set-password/request \
  -H "Origin: https://myapp.com" \
  -b cookies.txt

Programmatic (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 designuserId 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

CodeStatusCause
already_set400Account already has a password
invalid_code400Code doesn't match, is the wrong type, or userId doesn't match the token's user
code_used400Code was already redeemed
reset_token_expired410Past the 10-minute window
weak_password400Fails 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

CodeStatusCause
reset_token_invalid400Code doesn't match, or matches a token of the wrong type
reset_token_already_used410Already redeemed
reset_token_expired410Past SessionConfig.TokenTTL (default 1h)
weak_password400Fails PasswordPolicy
user_not_found404The 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

CodeStatusCause
user_not_found404Session's user no longer exists
already_verified400Email is already verified
email_failed500The mailer rejected the send

curl

curl -X POST https://api.myapp.com/auth/resend-verification \
  -H "Origin: https://myapp.com" \
  -b cookies.txt

Programmatic (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 handler

Client

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

On this page