go-auth
Guides

Security

Browser CSRF and SecurityConfig password policy — plus every self-service account action: name, password, verification, and deletion.

Security

BrowserConfig holds browser origins and cookie delivery; SecurityConfig holds password, optional email-change, and email-link policy. CSRF protection is mandatory. 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.

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.

Configuration

Browser origins and cookies live in WithBrowser. CSRF double-submit, strict origin checking, and 2FA challenge binding are mandatory. Bootstrap with GET /auth/csrf-token, read the JSON token, and echo it in X-CSRF-Token with credentials. All cookies are HttpOnly; sibling subdomains do not need a shared cookie domain. See Deployment.

WithSecurity retains password, optional email-change, and email-link policy:

goauth.WithSecurity(goauth.SecurityConfig{
    PasswordPolicy: domain.PasswordPolicy{
        MinLength: 12,
        RequireUppercase: true,
        RequireDigit: true,
    },
    AllowHTTPURLs: goauth.RequireHTTPSEmailLinks(),
})

Supplying a password policy replaces the default (MinLength: 8, RequireDigit: true). Repeat every requirement you need. Password length is measured in UTF-8 bytes and the maximum is 72 bytes. Violations return 400 weak_password.

Protect custom browser routes with the same two checks:

widgets := auth.CORS(auth.RequireCSRF(auth.RequireAuth(http.HandlerFunc(createWidget))))
mux.Handle("POST /widgets", widgets)
mux.Handle("OPTIONS /widgets", widgets) // direct browser writes need preflight

Auth.Mount already applies these checks to its browser mutation routes. There is no CSRF opt-out.

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

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/change-password \
  -H "X-CSRF-Token: $CSRF" \
  -c cookies.txt \
  -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)

Go examples on this page import github.com/nazimdjebloun/go-auth/api for operation inputs and results.

err := auth.Services().Password.ChangePassword(ctx, api.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, "POST", "/auth/change-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

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/set-password/request \
  -H "X-CSRF-Token: $CSRF" \
  -c cookies.txt \
  -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 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

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

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/set-password/confirm \
  -H "X-CSRF-Token: $CSRF" \
  -b cookies.txt \
  -c cookies.txt \
  -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, api.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": "..." }. With a mailer configured, returns the same generic success message for unknown, passwordless, and password-bearing accounts. Only an account with an existing password receives a reset link; an OAuth-only account must use its provider to sign in before using the set-password flow above.

Valid addresses are durably queued before account lookup. A queue failure returns internal_error (500) independent of account existence; no mailer returns email_not_configured. Token generation, storage, rendering, and delivery run afterward in the background, with retries. Success acknowledges acceptance, not delivery. See Schemas for the required queue table.

Timing-safe on purpose

The public response path performs input validation and the same queue insert for known and unknown valid addresses. It does not wait for account lookup, token generation, template rendering, or SMTP delivery. Missing and passwordless accounts are skipped by the worker.

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/forgot-password \
  -H "X-CSRF-Token: $CSRF" \
  -b cookies.txt \
  -c cookies.txt \
  -H "Content-Type: application/json" \
  -H "Origin: https://myapp.com" \
  -d '{"email":"ada@example.com"}'

Programmatic (Go)

err := auth.Services().Password.ForgotPassword(ctx, api.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

When the worker processes a new reset, it invalidates any earlier unused token for the same user — only the most recent link works. A retry can regenerate the token and send another link. On successful password replacement, every session for that user is revoked, 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

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/reset-password \
  -H "X-CSRF-Token: $CSRF" \
  -b cookies.txt \
  -c cookies.txt \
  -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, api.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

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/resend-verification \
  -H "X-CSRF-Token: $CSRF" \
  -c cookies.txt \
  -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

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/resend \
  -H "X-CSRF-Token: $CSRF" \
  -b cookies.txt \
  -c cookies.txt \
  -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") // ErrVerificationEmailSent acknowledges queue acceptance; other errors are surfaced 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