go-auth

Security

The library's overall security approach: passwords, sessions, CSRF, OAuth, rate limiting, access control, and what stays your responsibility.

Security

go-auth provides authentication controls; you remain responsible for deployment, transport security, secret storage, and operational response. This page describes the library's controls and their limits.

Password hashing and hasher migration

Defaults and policy

Passwords use bcrypt from golang.org/x/crypto/bcrypt at cost 12 by default. They are never stored or logged in plaintext. WithBcryptCost changes the work factor; WithPasswordHasher selects the implementation used for new password hashes.

PasswordPolicy (MinLength, RequireUppercase, RequireDigit, RequireSpecial) runs during registration and password changes. Its default requires at least eight characters and a digit.

Argon2id

Argon2id is available as the built-in hasher/argon2id package but is not the default, preserving bcrypt compatibility and zero-configuration behavior. Select it with WithPasswordHasher(argon2id.New(argon2id.DefaultOptions())). Its standard PHC string embeds version, memory, iteration, parallelism, salt, and key data, so verification uses each stored row's own parameters and the registry can dispatch on $argon2id$ without another interface method.

Argon2id's Memory is a real KiB allocation for every concurrent hash or comparison. More memory raises attacker cost and login-server capacity needs. DefaultOptions implements RFC 9106 section 4's memory-constrained recommendation: 64 MiB, 3 iterations, parallelism 4, a 16-byte salt, and a 32-byte tag. Benchmark the intended concurrency; the Go Argon2 documentation explains the parameter trade-offs.

Compatibility and migration

PasswordPolicy.Validate caps passwords at bcrypt's hard limit: 72 bytes, not characters. The limit stays in force with a custom hasher so every configured implementation accepts the same password set.

Stored hashes select their verifier by their own format prefix ($2a$, $2b$, $argon2id$, and similar), never by blindly trying whichever hasher is current. The registry always includes the current implementation plus built-in bcrypt and Argon2id verifiers, so switching between those algorithms does not break existing users' logins. An unknown or corrupted prefix fails closed: no verifier is guessed and the current hasher is never used as a fallback. The condition is logged for operators, while the caller receives the ordinary invalid_credentials response so a damaged row does not become an account-enumeration oracle.

Password pepper and rehashing

Password peppering is off by default. WithPasswordPepper enables a versioned keyring. For a versioned row, go-auth first computes base64(HMAC-SHA256(passwordPepperKey, password)); bcrypt or Argon2id then hashes that fixed-length value.

Pepper selection is explicit, not trial-based. users.password_pepper_version = NULL means the row is unpeppered and the KDF receives the raw candidate; a non-zero value selects exactly one key from PasswordPepperConfig.Keys before the same prefix-selected KDF runs. This makes verification O(1) regardless of keyring size and gives wrong passwords the same public invalid_credentials shape. An unavailable version fails closed and performs a dummy comparison through the real stored KDF and parameters when parseable, or through the real current KDF when the stored hash is malformed; it never falls back to another pepper or to the raw password.

After a successful comparison, go-auth checks the stored pepper version, algorithm, and self-described KDF parameters against the current configuration. An older pepper version (including NULL), legacy algorithm, changed Argon2id/scrypt/PBKDF2 parameter block, or bcrypt cost change triggers rehash-on-login. The password is hashed through the current configuration and both hash and version are replaced. The update is guarded by both old values, so a concurrent password reset or change wins instead of being overwritten. A stored pepper version greater than this node's current version is never downgraded, which keeps rolling deployments safe once the future key has been preloaded. A hashing or persistence failure is logged and leaves the verified login usable; the unchanged row is eligible for another upgrade attempt on its next successful login. There is no batch migration.

Login remains timing-safe against email enumeration. A missing email and an OAuth-only account both run a real-KDF dummy comparison before returning invalid_credentials. A missing email, OAuth-only account, wrong password, and unsupported stored prefix therefore have the same public error contract. Forgot-password requests for unknown accounts also run token generation, rollback-only cleanup and insertion queries, and template rendering before returning the usual response without sending an email.

User-entered codes

Every code a user types comes from crypto/rand. Two-factor codes are six digits with a five-minute default TTL. Email-verification, set-password, and delete-account codes are eight characters from an unambiguous 32-character alphabet: A-Z except I and O, plus 2-9. Their default TTLs are 15, 10, and 10 minutes. The code shapes and lengths are fixed in code, not configurable.

Use a numeric keypad for 2FA and a full keyboard for the other codes. The shorter 2FA code has a five-guess lineage cap; the longer codes provide more entropy where attempt caps are thinner or absent.

Sessions and tokens

Every session issues two opaque tokens, a session token and a refresh token, each 32 cryptographically random bytes (crypto/rand), hex-encoded. Only their SHA-256 hash is ever written to the database; the raw token exists solely in the Set-Cookie header sent to the client. A leaked database dump does not hand over live sessions.

Refresh tokens rotate on every use: presenting one invalidates it and issues a new one. A short grace window (default 5s) tolerates two requests racing to refresh the same token, meaning the previous token is still honored briefly, while any refresh attempt outside that window with an already-rotated token is treated as token_already_rotated, a signal worth monitoring for token replay. Sessions additionally support an idle timeout (default 7d), an absolute hard expiry (default 30d), and an optional absolute maximum lifetime from creation regardless of activity.

Cookies default to HttpOnly (never exposed to JavaScript) and Secure. The Secure flag is tri-state: left unset it derives to true everywhere except a http:// BaseURL in EnvironmentDev, so the same code is safe in production without extra configuration, and you have to opt out explicitly (goauth.SecureNever()) to weaken it. SameSite defaults to Lax.

CSRF

Two independent layers, applied to every state-changing request (POST/PUT/PATCH/DELETE):

  1. Origin/Referer checking (middleware.OriginCheck) is always on. Origin (falling back to Referer) must match AllowedOrigins or the request itself. Forwarded-* headers are trusted only from IPs in the rate-limit TrustedIPs list. AllowedOrigins rejects "*"; every accepted origin must be listed explicitly.
  2. Double-submit cookie token (middleware.CSRFToken) is on by default. Its configuration is created automatically because WithSecret is mandatory. The token is base64url(nonce).base64url(HMAC-SHA256(csrfKey, nonce)), generated with crypto/rand and signed with a CSRF-specific key derived from WithSecret. Go-auth verifies it in constant time with crypto/subtle.ConstantTimeCompare. Clients echo it in X-CSRF-Token by default. Cross-origin JavaScript cannot read the cookie, so it cannot construct a matching request.

Origin checking needs no configuration. The token layer also covers requests without Origin or Referer, or deployments that do not fully trust those headers.

Only the token layer can be disabled, through SecurityConfig.DisableCSRFToken. It is for CLI or server-to-server deployments with no browser clients. Disabling it logs a startup warning. Combining it with AllowMissingCSRFHeaders: true is rejected because a cross-site request could otherwise pass with neither a trusted origin nor a token.

OAuth

OAuth uses PKCE (S256) on every provider, including ones that do not strictly require it: a random 64-byte code verifier is generated per flow, its SHA-256-derived challenge is sent in the authorization request, and the verifier is only revealed at the token exchange. An intercepted authorization code is useless without it. Separately, a signed, single-use state token guards against CSRF on the callback and replay: reusing a state token returns state_used, and an expired one returns state_expired.

An OAuth-provided email is only treated as verified when the provider guarantees it. For GitHub, a non-empty public profile email specifically, since GitHub only allows verified addresses to be set as public. Linking a new provider to an existing account requires that account to already be authenticated (unauthorized otherwise), and unlinking is blocked (cannot_unlink_last_provider) if it would leave the account with no way to log in at all, with no password and no remaining linked provider.

The OAuth callback is the one HTML response in an otherwise all-JSON API. It sets cookies via Set-Cookie (unreliable on cross-origin 302 redirects in some browsers) and then redirects client-side via a small inline script. Every other endpoint's headers are left entirely to the host application, but this response carries its own X-Content-Type-Options: nosniff and Content-Security-Policy since it is the only place go-auth renders markup rather than JSON.

Two-factor authentication

Email-based 2FA is controlled globally by TwoFactorConfig.RequireEmail2FA and per user by TwoFactorEnabled. The following limits and operational effects matter when choosing it.

Admin login

POST /auth/admin/login requires a second factor by default. This is independent of RequireEmail2FA and the admin user's TwoFactorEnabled setting. Therefore, a mailer is required for admin login even when the rest of the deployment does not use 2FA.

Use TwoFactorConfig.DisableAdminTwoFactor through WithTwoFactor only for an API-only deployment with no email delivery. It removes the second factor from the highest-privilege login path. NewConfig still requires a mailer when RequireEmailVerification, EnableInvite, RequireEmail2FA, or DefaultEnabled is enabled. See Admin login for the request and response shape.

OAuth accounts

2FA covers password login (Login, AdminLogin, Register, and invite registration). It does not add a second factor to an OAuth login; go-auth trusts the provider to have authenticated the user. An account with both a password and a linked provider can therefore be accessed through that provider without this library checking its own 2FA. Do not offer OAuth linking where 2FA must protect every login path.

Attempt limits

Each six-digit 2FA code allows five wrong guesses and three resends per challenge. Guarded database writes enforce those caps across concurrent requests. A new Login, AdminLogin, or Register request can mint a new challenge and a new five-guess budget; the cap is not an account-level brute-force limit.

The per-IP limits on /auth/2fa/verify (default 5/min) and /auth/login provide the brute-force control. They do not stop an attacker distributing guesses across many IPs. Use an edge control that rate-limits per account or credential-stuffing signal if that guarantee is needed.

Twenty failed 2FA attempts on one account within an hour send a notification email. It never blocks login. Treat it as a signal to investigate or rotate the password, not proof of compromise; someone who knows the account email can trigger it.

Mail delivery availability

With email verification, a mailer outage only blocks new signups. With RequireEmail2FA: true, Challenge sends a code during each password login, so a mailer outage blocks that path. Use TwoFactorConfig.DefaultEnabled for per-user 2FA that is enabled by default but can be disabled.

Rate limiting

Rate limiting is on by default. An in-memory store applies tighter limits to login, admin login, registration, password reset, and verification resend, with a looser default for other routes. Disabling it logs a startup warning; do so only when an edge component applies equivalent limits.

Deployment topology determines the CSRF configuration. Same origin needs no additional configuration; sibling subdomains need a scoped CSRF cookie; different registrable domains need SameSite=None and ExposeCSRFTokenInBody. See Deployment.

The address recorded on a session or an audit event follows the same rules. middleware.ClientIP reads IPAddressHeader only when the immediate peer is in TrustedIPs, takes the rightmost hop that is not itself a trusted proxy, and falls back to the connection address when the header is absent, untrusted, or unparseable. A client can never write its own audit trail. It differs from the rate-limit key in one way: IPv6 is not masked to a subnet, because this is a record of who connected rather than a bucket to group them into.

Both halves of the counter key are bounded, which is what keeps the limiter from becoming an attack surface of its own. The route half is the matched route pattern, not the request path, so varying a path parameter cannot mint a fresh counter per request. The client half is always a parsed, normalized address: a forwarded-for header (IPAddressHeader) is only read when the immediate connecting peer is in your configured TrustedIPs, only its rightmost hop that is not itself a trusted proxy is used, and a value that does not parse as an IP is discarded in favour of the connection address rather than used as-is. A client behind no trusted proxy cannot spoof the header to reset its own limit, attribute its traffic to someone else's bucket, or grow the store with key material of its own choosing.

The default store is bounded too. It holds at most 100,000 counters (ratelimit.WithMaxEntries to raise it) and drops one to make room when full, preferring expired counters and then the sampled counter with the most budget left. The ranking is chosen so the policy is not steerable: an eviction order an attacker can drive is a rate-limit bypass, so a flood of fresh single-hit counters is made to evict itself rather than the near-limit login counter it would rather you forgot. Eviction warns (throttled) and is visible through the store's Stats().

Access control

Every endpoint that reads or mutates a specific resource by ID checks ownership, not just authentication:

  • Sessions: revoking a session by ID returns the same session_not_found whether it does not exist or belongs to someone else, so one user cannot distinguish "no such session" from "that is not yours" by probing IDs.
  • Organizations: every /auth/orgs/{orgID}/* route requires membership in that specific org, and mutating routes additionally require a minimum role (admin to manage members/invites/settings, owner to delete the org or touch another owner). Demoting or removing the last owner is blocked outright (cannot_remove_last_owner), the same way admin management blocks removing the last admin (last_admin).
  • Admin: every /admin/* route requires role: admin, checked after authentication, not instead of it.

Audit logging

When enabled, security-relevant events (login, logout, registration, password changes, session revocation, admin actions, OAuth linking, org changes) are queued and flushed asynchronously to the database and any configured sinks. Publishing an event never blocks or fails the request that triggered it: a full queue drops the event with a logged warning rather than propagating back to the caller. FailureMode only governs what happens when a sink itself errors during a flush: fail-open logs and continues to the next sink; fail-closed logs and stops that batch early. Either way, the original HTTP request has already completed by the time this runs.

Secrets

WithSecret supplies the required application-wide root secret and must be at least 32 bytes (NewConfig rejects anything shorter, since HMAC-SHA256 wants a full-strength key). It is never used directly as key material: HKDF-SHA256 derives four independent subkeys with distinct purpose strings: CSRF signing, 2FA challenge binding, OAuth token encryption, and OTP pepper. A compromise of one derived purpose's key does not hand over the others. Nothing about the secret or its derived keys is logged or exposed via any endpoint.

The two pepper features are deliberately separate. OTPPepper is always derived from WithSecret using goauth-otp-pepper-v1 and protects low-entropy 2FA, verification, set-password, and delete-account codes. The optional password pepper takes independently versioned input from WithPasswordPepper and derives each version's HMAC key with goauth-password-pepper-v1. They address different offline-guessing threats and are not interchangeable; no password operation reads the OTP pepper, and no OTP operation reads the password pepper.

Rotating WithSecret invalidates every outstanding CSRF token and 2FA challenge binding, makes previously stored OAuth provider tokens undecryptable, and changes the OTP pepper. It does not change the independently configured password pepper or invalidate password hashes. There is no general dual-key rotation window for the affected keys today, so plan rotations around those consequences rather than treating the root secret as a drop-in value change.

Password pepper

Password peppering is optional defense in depth against a database-only disclosure; bcrypt or Argon2id remains mandatory underneath it. Enable it with WithPasswordPepper(PasswordPepperConfig{...}), using at least 32 bytes of independently generated secret material per version, stored outside the password database and separately from WithSecret. Omitting the option leaves passwords unpeppered and skips the HMAC operation entirely. Selecting a current version whose key is missing or empty fails configuration at startup, so an unset environment variable cannot silently weaken a deployment that intended to opt in.

All instances must receive the same version-to-secret mapping. Key derivation is pure and deterministic and introduces no boot time or other per-process state, so no rotation-timestamp field is needed. This differs from PepperRotatedAt, which records the operator-controlled time the OTP pepper changed; that timestamp is not key material and has no role in password verification.

New registration, invite registration, set-password, and admin-created users pass through the same password pipeline and persist its current version beside the hash. Password change and reset normally use that version too, but an older rolling-deploy node preserves the row's higher version when it has the preloaded key; if that key is unavailable, hashing fails closed instead of falling back to a lower version. Existing NULL rows verify unpeppered and then use the same guarded rehash path described under Password hashing and hasher migration. Versioned rows select one exact historical key; verification never loops over the keyring.

Rotation uses monotonically increasing versions: preload the next key on every instance, then raise CurrentVersion, retain old keys while rows lazily upgrade on login, and remove a key only after no row references it. Every replacement of an existing password, whether login rehash, change, or reset, uses one compare-and-swap guarded by the previously read hash and pepper version. The SQL also rejects a new version lower than the stored version, so a stale or misconfigured node cannot overwrite a concurrent upgrade. Change and reset return password_update_conflict when their snapshot loses that race; login treats the same result as a harmless skipped best-effort rehash.

Password reset is one database transaction after the intentionally out-of-transaction KDF work: it atomically claims the exact token only while its ID, hash, user, type, expiry, and unused state still match; applies the password hash/version compare-and-swap; and deletes every session for that user. Email verification likewise claims its exact token and updates the user in one transaction. A concurrent use can claim either token exactly once. A conflict, database error, or session-revocation failure rolls back the token and its authorized state change together. OAuth callbacks claim their state row with a guarded update before contacting the provider, so concurrent callbacks cannot both pass the library's single-use check. Change-password likewise commits its password replacement and session revocation together, including the keep-current-session variant. Set-password confirmation claims its code with the same conditional single-use update before writing the password, so concurrent confirms cannot both consume it. Invite redemption claims the invite and creates the account in one transaction; organization invite acceptance deletes the row only while its code hash still matches, so rotating the code invalidates in-flight redemptions of the old one. OAuth registration creates the user and its provider link in one transaction, so a failed link can never strand a passwordless account that later retries reject as an existing email. Provider unlinking serializes concurrent unlinks on the user's provider rows before re-checking the last-method guard, so two parallel unlinks cannot both delete and lock out a passwordless user. Organization membership removal and role changes assert the previously read role in the write itself; a lost race rolls back the denormalized counter upkeep instead of applying it twice. Audit publication occurs only after commit and is not part of the database transaction.

New() validates distinct stored versions whenever a keyring is configured. The database ping and stored-version query share a 10-second startup deadline, so an unavailable database fails construction instead of leaving startup blocked indefinitely. Never replace key material under an existing version. Losing a referenced key locks out those rows; if a pepper is compromised, forced password resets may still be appropriate because rotating it cannot revoke an attacker's copy of the leaked database and old key. PepperRotatedAt cannot solve that: a timestamp can classify a short-lived OTP as stale but cannot recreate password-verification key material. See OWASP's password pepper guidance before opting in.

Secret rotation and the OTP pepper timestamp

Codes issued before a rotation were hashed with an HMAC under a pepper that no longer exists, so they can never verify without special handling and would otherwise fail as invalid_code, indistinguishable from a typo. Pair every secret rotation with WithPepperRotatedAt, set to the moment the new secret went live:

goauth.NewConfig(
    goauth.WithSecret(os.Getenv("AUTH_SECRET")),
    goauth.WithPepperRotatedAt(time.Date(2026, 9, 11, 18, 0, 0, 0, time.UTC)),
    // ...
)

Pre-rotation codes then fail as code_expired / two_factor_code_expired (and the set-password / delete-account equivalents), the client's resend branch, without running the HMAC and without burning per-challenge attempt budget. Resending always mints under the live pepper, so one resend recovers. Unset (the zero default) disables this branch: verification is purely by HMAC and rotated codes surface as invalid_code.

This value must be identical on every instance behind a load balancer: it is a shared fact about when rotation happened, not a per-process observation. Source it the way you source the secret itself: one environment variable (or secrets-manager entry) read by all instances, never per-instance config files that can drift. Two instances holding different timestamps disagree per-request about whether the same code is stale, which is exactly the failure this mechanism exists to prevent. Stating "issued before last rotation" in a response is not an information leak: it reveals no keyspace bits, confirms no account existence, and the stale branch performs no comparison an attacker could oracle.

What is not covered

This library handles auth-specific correctness. It does not, and cannot, cover:

  • Transport security: TLS termination is your responsibility (a reverse proxy, load balancer, or the Go server itself); the library only reacts to whether the connection looks secure (via BaseURL's scheme and Environment).
  • Database security: encryption at rest, network access control to your database, and backup handling are your infrastructure's job.
  • Secret storage: where AUTH_SECRET, SMTP credentials, and OAuth client secrets live (a secrets manager vs. a plain environment variable) is a deployment decision the library does not make for you.
  • DDoS/edge protection: the built-in rate limiter protects application-level endpoints from abuse; it is not a substitute for a CDN or WAF against volumetric attacks.
  • Dependency and Go-version hygiene: keeping the module and its transitive dependencies patched is an ordinary maintenance task like any other Go project.
  • Multi-instance rate-limit consistency: the default in-memory Store is per-process; running multiple instances behind a load balancer needs WithRateLimitStore pointed at a shared store (Redis, etc.) for limits to apply globally rather than per-instance.
  • Tenant-owned application queries: organization middleware authorizes membership and role, but it cannot rewrite SQL issued by the consuming application. Use RequireOrgScope or RequireActiveOrgScope so the authorized goauth.OrgScope is a required handler argument, pass that complete scope into repository methods, and include scope.OrgID in every tenant-owned read, update, and delete predicate. A query filtered only by resource ID can become a cross-tenant disclosure even though the route itself was authenticated.
  • URL-borne token leakage on your landing pages: password-reset, invite, and org-invite links embed the raw high-entropy token in the query string (?token=...), the conventional approach and not a defect by itself, but it carries the standard risks of any URL-borne secret: Referer leakage if the landing page loads third-party resources, and retention in browser history/autocomplete and server/proxy access logs. Set Referrer-Policy: no-referrer (or same-origin) on the pages that read these tokens, and consider stripping the token from the visible URL (e.g. history.replaceState) once you have read it client-side. There is no library-side mitigation for this: go-auth issues the token and the link; what the browser does with the URL after that is your frontend's call.

On this page