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. WithPasswordHashing's BcryptCost changes the work factor; its optional Hasher selects the implementation used for new password hashes and takes precedence over BcryptCost.
PasswordPolicy (MinLength, MaxLength, RequireLetter, RequireUppercase, RequireDigit, RequireSpecial) runs during registration and password changes. An entirely zero policy selects 8..128 Unicode code points with a letter and digit. A nonzero policy uses all composition flags literally; PasswordPolicy{MinLength: 12} requires length only. Zero lengths resolve to 8 and 128. Invalid length/rule combinations fail configuration validation. Passwords must be valid UTF-8; they are not Unicode-normalized or trimmed before hashing.
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 WithPasswordHashing(PasswordHashingConfig{Hasher: 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.
Password-work capacity
Password hashing and verification share a limiter that allows four active
operations per Auth by default. PasswordHashingConfig.Concurrency changes the cap;
PasswordHashingConfig.WorkLimiter shares capacity across instances in one process.
This applies to bcrypt, Argon2id, custom hashers, legacy verification, real-KDF
dummy checks and startup probes. HTTP and direct service calls use the same
registry. Standalone calls to a hasher bypass the registry's protection.
Saturated work is rejected immediately with password_work_busy (HTTP 503),
including missing-email and passwordless-account login attempts. Overload is
propagated separately from incorrect credentials. Overloaded password writes leave
credentials and one-time tokens unchanged; optional rehashing can be skipped
after successful login verification. The limiter does not queue requests or
cancel an already-running KDF. Capacity remains held until the KDF finishes.
The cap bounds active operations, not total RAM or total requests. Four default Argon2id operations need roughly 256 MiB of working memory, with additional headroom for the application and garbage collection. Verification honors stored parameters, so budget for older hashes with higher costs too. Multiple private limiters and replicas have independent budgets. Keep route/IP rate limiting and edge protection in place. See password work limits.
Compatibility and migration
Character policy is separate from algorithm limits. Bcrypt's 72-byte bound is checked before hashing and excess input returns weak_password. It does not constrain Argon2id. With password peppering, the bound applies after preprocessing, to the fixed 44-byte HMAC/base64 representation. Custom hashers can declare a bound through port.PasswordInputLimiter; the startup probe respects it. Typed-nil hashers and negative input limits fail configuration instead of being invoked. Startup rejects a minimum that cannot fit a known unpeppered input limit, or a hasher too narrow for the pepper representation. Login verification never applies the current write policy to existing hashes; a legacy Argon2id password can still log in after switching new writes to bcrypt, even when it cannot be rehashed into bcrypt.
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. WithPasswordHashing's Pepper 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 uses a real-KDF dummy comparison for missing emails and OAuth-only accounts before returning invalid_credentials. The dummy uses the configured algorithm and parameters through the same password pipeline, including when password peppering is enabled. Missing emails, OAuth-only accounts, wrong passwords, and unsupported stored prefixes share the same public error contract. Public forgot-password and verification-resend requests enqueue the same address-and-operation record before any account lookup. Account checks, token generation, rendering, and email delivery happen in a background worker, outside the public response path.
Successful public recovery responses acknowledge queue acceptance, not delivery or account existence. Queue failures return internal_error for every valid address; a missing mailer returns email_not_configured before account lookup. The worker skips missing accounts, passwordless accounts for password reset, and already-verified accounts for verification. The queue stores no raw token or code.
Each instance runs one worker polling every 250ms. Claims have a two-minute lease, and each processing attempt has a 30-second context deadline. Claims count toward a five-attempt budget; failures retry after 10, 20, 40, and 80 seconds. Delivery is at least once: a crash after sending but before acknowledgement can send again and regenerate the credential. Requests become ineligible after 24 hours; pruning runs every minute and retains dead letters until seven days after creation. Auth.Close cancels delivery and stops the worker before closing database resources. Custom mailers must honor cancellation and deadlines. Monitor processing errors, backlog, and dead letters. The recovery table is included in the canonical schema.
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
Password-authenticated session and challenge issuance rechecks the verified hash, pepper version, and authentication requirements under a user-row lock. A concurrent password reset/change either revokes the newly issued session/challenge or causes the stale login to return invalid_credentials. 2FA token consumption, session creation, last-login update, and the verification audit event commit in one transaction under that same user lock. Delivery of a challenge email happens after the challenge transaction commits and cannot recreate a challenge deleted by a password replacement.
Every session issues two opaque tokens, a session token and a refresh token, each containing 32 cryptographically random bytes (crypto/rand), hex-encoded by the default generator. Admin credentials additionally carry a context prefix. Only the SHA-256 hash of the complete credential is written to the database; the built-in HTTP handlers deliver raw tokens through Set-Cookie, while programmatic creation and rotation return them once to the caller for its own transport. 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; a previous token presented after that window revokes the session as suspected replay. Sessions additionally support an idle timeout (default 7d), a refresh-token expiry (default 30d), and an optional absolute maximum lifetime from creation regardless of activity. Refresh cannot revive a session after its idle timeout or maximum lifetime, and access-token validation enforces the maximum lifetime even if the access-token expiry is later.
Cookies are HttpOnly (never exposed to JavaScript). The Secure flag is tri-state: left unset it derives to true everywhere except an HTTP public API origin in EnvironmentDev. BrowserConfig.APIURL supplies that origin, falling back to the origin of AppConfig.BaseURL. goauth.SecureNever() is allowed only in development; SameSite=None always requires Secure. SameSite defaults to Lax.
CSRF
Ordinary unsafe browser routes enforce both an exact origin allow-list and a signed double-submit token. Neither layer can be disabled. The origin comes from Origin, falling back to Referer; a mutation with neither is rejected. The effective API request origin itself is also accepted, honoring forwarded host/scheme only through configured trusted proxies. OAuth callbacks instead use their existing single-use, browser-bound OAuth state.
The frontend calls GET /auth/csrf-token with credentials and receives 200 {"token":"..."} and Cache-Control: no-store. The server sets a matching HttpOnly _csrf cookie, host-only at /. JavaScript keeps the JSON value in memory and echoes it in X-CSRF-Token on unsafe requests. It never reads document.cookie for CSRF.
Tokens contain a cryptographically random nonce and an HMAC-SHA256 signature under a CSRF-specific key derived from WithSecret. The signed input includes a purpose prefix, a length-delimited binding, and the nonce. Before login the binding is a signed, random HttpOnly browser cookie; after login it is the stable session ID. A token from another browser or session is rejected even when its cookie and header match. Refreshing raw session credentials preserves the session binding.
Authentication and credential changes (including OAuth linking/unlinking and 2FA settings) rotate the token and return its replacement through X-CSRF-Token, exposed by CORS. Password reset re-resolves the remaining browser/session identity after revocation. Safe requests repair missing, invalid, or stale tokens. Clients may bootstrap and retry once only for csrf_token_missing, csrf_token_mismatch, or csrf_token_invalid, which are middleware rejections before handler execution; do not blindly retry writes on other errors.
Auth.RequireCSRF applies both checks to custom routes. Direct service calls do not process HTTP requests: custom handlers must use this middleware and the request-aware cookie helpers. Missing middleware signing dependencies fail closed.
JSON token delivery supports sibling subdomains without widening cookie domains. It does not expose session credentials or provide protection from XSS; allowed frontend code can access the CSRF token by design. See OWASP's signed double-submit guidance and Deployment.
App and admin contexts
X-GoAuth-Session: admin selects the independent admin cookie context; omission selects app. The header is a selector, never an authorization claim. Unknown or repeated values are rejected. Authentication never falls back to the other context's cookies. Both contexts use mandatory origin checks and signed double-submit; their CSRF signatures and anonymous browser bindings use separate purpose labels.
Admin session and refresh credentials include a context label before their complete opaque value is hashed. Editing the label changes the hash and cannot convert an existing app credential into an admin credential. Refresh preserves the original context. App/admin 2FA challenges have separate pending-code lineages and binding cookies; verify, resend, and cleanup cannot consume the other context's challenge. Challenge IDs remain UUIDs. OAuth state carries its initiating context through a callback without a custom header; its full hash, browser-state cookie, expiry, and single-use checks still apply.
Ordinary logout only revokes and clears the selected current session. Session listings and explicit revocation retain their account-wide behavior; password reset, account deletion, and revoke-all operations are not restricted to one context. Session isolation does not isolate account data or protect against compromised trusted frontend code. Both contexts inherit the same cookie transport policy.
OAuth
Callbacks parse GET queries and bounded URL-encoded POST forms explicitly. POST bodies are limited to 64 KiB; multipart and other representations are rejected before state consumption. Callback parsing never creates multipart temporary files.
Unlinking a provider deletes all of that user's identities under the selected provider name. The last-method guard counts only a password or identities under other provider names, so multiple identities under one provider cannot bypass it.
Google and GitHub reject non-success profile HTTP responses and missing subject IDs. The shared callback also rejects nil profiles, blank subject IDs, and provider names that do not match the requested provider before any identity lookup or linking. A new OAuth account requires a valid email address. A previously linked identity can still authenticate when its current profile omits email. Invalid profiles return the generic provider_error.
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. A random, single-use state token is stored by hash and bound to the initiating browser with a short-lived, host-only HttpOnly cookie; the callback requires both copies. Reusing a state token returns state_used, and an expired one returns state_expired. Provider linking also binds the state to the exact initiating session token and validates that session again at callback.
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.
Provider-link finalization rechecks the current user, exact initiating session token and already-claimed state's bindings and expiry after provider exchange. The user and session stay locked through the provider-account insert in one transaction. A reset, email replacement or session revocation that commits first therefore prevents an in-flight callback from adding a new authentication method. The provider request itself does not hold database locks. The link audit event retains its existing post-commit, non-transactional delivery behavior.
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. This HTML response sets X-Content-Type-Options: nosniff and Content-Security-Policy. Built-in JSON responses set their content type, error writers also set nosniff, and the CSRF-token endpoint sets Cache-Control: no-store; the host application supplies any additional headers.
Two-factor authentication
The successful 2FA claim reasserts the challenge ID, code hash, user ID, token type, unused state, current expiry, and attempt cap in the same SQL update. A resend committed after code comparison makes the old-code claim fail, and expiry is checked again at the claim time. A failed claim creates no session and does not consume the current code.
Email-based 2FA is controlled globally by TwoFactorConfig.RequireEmail2FA and per user by TwoFactorEnabled. The following limits and operational effects matter when choosing it.
Authentication policy matrix
This table describes session issuance with an enabled authentication method. When signup has not issued a session, complete email verification and sign in. An existing session restricted by verification resumes access after verification.
| Path | Email verification when required | Local email 2FA |
|---|---|---|
| Password registration | Returns RequiresVerification without a session. | If verification is not required, challenges when RequireEmail2FA is on. |
| Password invitation signup | The accepted invitation establishes address ownership. | Challenges when RequireEmail2FA is on. |
| Password login | An unverified account must complete verification before receiving a session. | Challenges when globally required or enabled on the account; admin default also applies. |
| OAuth registration or login | A provider-verified matching address satisfies verification; otherwise verification completes before unrestricted access. | Global, per-account, and default admin requirements apply. |
| OAuth invitation signup | The provider email must match the invitation. If not provider-verified, an emailed code is confirmed before account creation. | Global, per-account, and default admin requirements apply after address proof. |
| Existing session | Unverified accounts receive email_not_verified on protected requests; verification, minimal session status and logout remain available. | Existing assurance checks continue to apply. |
DefaultEnabled initializes the personal setting for new password, invitation,
and OAuth accounts. It does not update existing accounts. Password and password
invitation signup gate their initial session only on the global requirement;
OAuth signup also gates its initial session only on the global requirement. Users can change
optional personal 2FA, but cannot override a global or admin requirement.
The OAuth rows describe login/registration callbacks. Linking a provider keeps the existing authenticated session and does not issue a replacement. Low-level programmatic session creation assumes that its caller has already completed the appropriate authentication checks.
Admin login
Admin accounts require a second factor by default through ordinary password login, admin login, and OAuth. This is independent of RequireEmail2FA and the admin user's TwoFactorEnabled setting. A mailer is therefore required even when the rest of the deployment does not use 2FA. Successful verification writes sessions.two_factor_verified_at as part of session creation; refresh preserves it. Admin HTTP routes and Auth.RequireAdmin reject sessions without that assurance as 403 two_factor_required, including old sessions and first-factor sessions belonging to newly promoted admins.
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, InviteSignup, RequireEmail2FA, or DefaultEnabled is enabled. See Admin login for the request and response shape.
OAuth accounts
OAuth login returns an email challenge before issuing a session whenever the global, account-level, or admin policy requires it. The callback sets the existing HttpOnly challenge binding cookie and redirects with step=two_factor, challengeId, and expiresAt; finish through POST /auth/2fa/verify. A provider's verified-email claim establishes address ownership, not completion of the library's second factor. Connecting a provider does not bypass local MFA.
Optional 2FA settings without a password
A session alone cannot change the setting. Password accounts can confirm with
an enabled password method. Passwordless users request an action-specific code
at POST /auth/2fa/settings/challenge and submit challengeId plus code to
/auth/2fa/enable or /auth/2fa/disable. The proof is bound to the account,
current email, action and initiating session. It expires after CodeTTL, allows
five guesses, is consumed with the setting change, and cannot issue a login
session. Repeated requests reuse a live challenge without sending more email.
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
Verification, set-password, and account-deletion requests remove their own unused code when rendering or delivery fails. Cleanup has a separate five-second deadline even if the request was canceled, and does not remove codes issued by another request. A cleanup failure is returned as a server error. Successful mailer acceptance does not guarantee final delivery, especially with an asynchronous mailer.
An account waiting for email verification needs working email delivery before it can complete that flow. With RequireEmail2FA: true, a password or OAuth login that needs a new challenge also needs successful mailer acceptance. Use TwoFactorConfig.DefaultEnabled to seed per-user 2FA for password, invitation and OAuth registrations while allowing the user to disable it later.
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.
All frontends use the same JSON CSRF bootstrap. Sibling subdomains can keep host-only Lax cookies; different sites require secure SameSite=None cookies and browser support for third-party cookies. 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
Signup invite resend uses a conditional code rotation restricted to pending/expired rows, and revocation changes only the current state. Accepted and revoked invitations are terminal for resend; expiry reads do not persist snapshots. A stale resend cannot re-enable a revoked invite, and it sends no email when the conditional update loses a race.
Organization self-service checks and locks the current actor membership inside the mutation transaction. Adding an Owner requires an Owner; removing another Owner likewise requires an Owner, even when other Owners remain. A member can still leave their own organization subject to the last-owner guard. Role changes lock both actor and target before deciding whether owner authority is required. Platform-admin override methods retain their separate authorization.
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_foundwhether 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 (adminto manage members/invites/settings,ownerto 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). - Active organization: selecting an active org locks the membership until the session update commits. Concurrent removal or role change then clears or updates the session scope before its own transaction commits, so a delayed selection cannot restore an old role.
- Admin: with app permissions disabled,
/admin/*checks the currentrole: adminand required session assurance. With app permissions enabled, administrative app actions check their current exact library key; an absent definition leaves the action protected-admin-only. Catalog provisioning and organization oversight require the protected admin identity. Privileged direct service calls also enforce current authorization and required assurance through trusted actor/session IDs.Auth.RequireAdminalways guards the protected administrator. Standalonemiddleware.RequireRolechecks the fixed account role and should not be used for app authorization.
App permission boundaries
WithAppPermissions selects users.app_role_id as the sole app grant source.
Built-in admin identity is the immutable app_roles.system_key=platform_admin,
not a role name or the users.role column. Library install/remove is
non-delegable. Delegated role management is bounded by the actor's currently
held keys and blocks self role editing/assignment and protected-admin transitions.
Permission checks do not replace resource ownership or organization membership.
Business-permission deletion also checks the actor's held-key delegation bound and refuses definitions referenced by any role. An unused definition needs a protected administrator for deletion; delegated definition-delete access alone does not authorize deleting arbitrary unused keys.
The schema binds app_permissions.is_system to the exact lowercase goauth.app.
key prefix with a CHECK constraint. Direct inserts and updates cannot mark a
library key as business-owned or a business key as library-owned. The service
also validates key syntax and the fixed library catalog.
Library permission removal cleans all grants atomically and affects subsequent checks in existing sessions. Reinstallation grants nobody automatically. Business keys that are missing or disabled deny admin too. Authorization management writes recheck the current actor under the common account guard and app singleton lock; stale writes fail rather than overwrite newer revisions. This does not cancel work already authorized by a consumer's earlier check.
Transactional session assurance uses a shared locking read on PostgreSQL and MySQL. A joined MySQL repeatable-read transaction cannot authorize management using a session snapshot from before revocation, deletion, or removal of MFA assurance. Deadlock and serialization failures propagate and roll back the transaction; the library does not retry individual SQL statements. Retry the whole operation in a fresh transaction so authorization is checked again.
Ordinary signup inserts the account and assigns its enabled non-admin default in one transaction, without taking the global management guards. PostgreSQL and MySQL hold a shared default-role lock through commit; SQLite's account insert already holds its writer lock. A default disabled or deleted before that shared read rejects signup and rolls back its account, invite claim and provider link.
Only the protected admin role requires second-factor assurance by default.
Custom roles follow the non-admin MFA policy, including roles with library
grants. Their password/OAuth logins and library actions require assurance when
RequireEmail2FA or the account's TwoFactorEnabled is on. Promotion to the protected
admin role requires a newly challenged login before an existing first-factor
session can perform administrative actions. Disabling default admin MFA leaves
global and per-user requirements in force. Trusted local CLI provisioning uses
database access instead of a browser session and records fail-closed database
audit events. Runtime APIs expose no equivalent assurance bypass.
Audit logging
When enabled, security-critical transactional paths (registration, successful login session issuance, password change/reset, session creation/revocation, account deletion, and role changes) record their audit event in the same database transaction as the state change: a successfully written record commits or rolls back with that change. Under the default fail-open policy, a failed audit write can leave a committed change without a record. Standalone observations such as failed login attempts have no state transaction and are durable direct inserts. When external sinks exist, the same write adds one audit_outbox obligation per event; the dispatcher fans it out to the configured sink set with exponential-backoff retries (10s base, 1h cap) up to MaxAttempts, then dead-letters it (retained for DeadLetterTTL as failure evidence). Delivery is at-least-once: receivers must deduplicate on the event id, including when a later sink's failure causes an earlier successful sink to receive the fan-out again. FailureMode only governs fan-out across sinks within one dispatch batch. The separate EnqueueFailureMode resolver (nil = fail-open everywhere) decides, per event, whether a failed durable write blocks the triggering operation — fail-closed only fires where a transaction exists to roll back and degrades to fail-open at non-transactional sites. Startup rejects missing outbox migrations, unbounded sinks, and claim leases that cannot cover a sink's declared worst-case batch time.
Password reset and password change also invalidate unused email 2FA login challenges in the same transaction as the password and session changes.
Both operations also delete unused password-reset tokens in that transaction. A reset link issued before a completed password change cannot replace the new password, even when the caller keeps its current session. Reset-token issuance locks and rechecks the password/pepper/email snapshot before storing a link; issuance that loses a concurrent credential change sends nothing. Reset redemption takes the user lock before claiming its token, preserving the credential flows' user-before-token lock order. Delivery after commit cannot recreate a revoked link.
Admin bans commit the ban, session deletion, and audit record together; unbanning and disabling 2FA commit their state change and audit record together. Enabling 2FA commits the enabled flag, any requested deletion of other sessions, and its audit record together. A failed revocation rolls back the state change. A failed audit write rolls it back only when the event's EnqueueFailureMode is fail-closed; the default fail-open policy logs/counts the failure and permits the change.
Secrets
Startup validates the complete cloned configuration after resolving defaults and before deriving any key. A previously successful NewConfig call does not authorize later changes that weaken the root secret, violate browser transport or CSRF requirements, or violate other configuration requirements.
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 six independent subkeys with distinct purpose strings: CSRF signing, 2FA challenge binding, OAuth token encryption, OTP pepper, Redis snapshot encryption, and Redis key names. 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 PasswordHashingConfig.Pepper 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 WithPasswordHashing(PasswordHashingConfig{Pepper: PasswordPepperConfig{...}}), using at least 32 bytes of independently generated secret material per version, stored outside the password database and separately from WithSecret. Omitting Pepper 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 password peppering has no rotation-timestamp field. SecurityConfig.SecretRotatedAt instead records when the current application secret (WithSecret) became active. It classifies older short-lived codes 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 records for these flows are written in independent savepoints inside the same transactions, so each record commits with — or rolls back with — the state change it describes.
For code-confirmed account deletion through the coordinator wired by goauth.New, the validated deletion code is conditionally consumed in the same database transaction as membership/counter maintenance, session removal, and user deletion. A failed token write or a later deletion failure rolls the claim back. Successful user deletion may cascade the token row; the service does not attempt another token write after commit. The audit record joins the same transaction. Existing databases must have the documented foreign-key configuration in place.
Password-confirmed deletion performs the KDF before acquiring locks, then
rechecks that exact password hash, pepper version and authentication metadata
under the coordinator's account-mutation guard. A password change/reset or
authentication-state change that wins that race rejects the stale deletion as
invalid_credentials; a current ban returns user_banned. The recheck precedes
all membership, counter, session and user deletion writes and their audit event.
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. SecurityConfig.SecretRotatedAt 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.
Set-password confirmation also requires the stored password hash to be NULL when writing. If a distinct live code or another credential write installed a password first, the losing confirmation rolls back its code claim and leaves that password unchanged.
The final set-password claim also checks the exact token ID, hash, owning user, set-password purpose, unused state and expiry. Its decision time is sampled after acquiring the user and token locks, so hashing and database waits do not extend a code's lifetime. The user's current email must match the address proved by the code. Token consumption and password installation join the same transaction, including an existing caller transaction; outer rollback restores both rows.
Secret rotation and the OTP pepper timestamp
Rotating WithSecret changes its derived OTP pepper. Codes hashed under the old pepper can no longer verify. Record the UTC moment the new application secret became active in SecurityConfig.SecretRotatedAt. Include it in your existing WithSecurity call alongside your other settings; each call replaces the whole group. For example, these options use the default security policy and record a rotation:
goauth.WithSecret(os.Getenv("AUTH_SECRET")),
goauth.WithSecurity(goauth.SecurityConfig{
PasswordPolicy: domain.PasswordPolicy{ // optional; all-zero selects defaults
MinLength: 8, // optional, default 8
MaxLength: 128, // optional, default 128
RequireLetter: true, // optional; default true for an all-zero policy
RequireUppercase: false, // optional; default false for an all-zero policy
RequireDigit: true, // optional; default true for an all-zero policy
RequireSpecial: false, // optional; default false for an all-zero policy
},
AllowEmailChange: false, // optional, default false
AllowHTTPURLs: nil, // optional, default nil: allow HTTP only in development
SecretRotatedAt: time.Date(2026, 9, 11, 18, 0, 0, 0, time.UTC), // optional, default zero
}),SecretRotatedAt is metadata: setting it does not rotate any key. There is no separate OTP-pepper timestamp or password-pepper timestamp. Password pepper rotation uses WithPasswordHashing's versioned Pepper keys independently of WithSecret.
Pre-rotation 2FA codes return two_factor_code_expired; set-password and
delete-account codes return their expiry errors. Those flows locate the stored
challenge by ID or account before checking its creation time, without comparing
the submitted code or consuming its attempt budget.
Email verification has a different lookup: it hashes the submitted code with the
current pepper before finding a row. After an actual pepper change, an old
code cannot locate its old hash and returns code_invalid, even with the
rotation timestamp configured. Request a fresh verification code for that
response as well. Resends generate codes under the live pepper. Leaving the
timestamp unset disables timestamp-based expiry classification; invalid-code
errors still reject codes that no longer match the current pepper.
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.
Profile and email-verification writes
The built-in SQL repository changes only name and updated_at for a name change. Email verification changes only verification timestamps/status and updated_at, conditional on the stored email still exactly matching the address being verified. This prevents those operations from overwriting unrelated fields from an older user snapshot.
Email-code verification consumes the code and performs the conditional write in one transaction. If the email has changed, verification returns code_invalid and the transaction rolls back the code claim. Linked-provider login upgrades verification only when the provider's verified email exactly matches the local email; a failed verification write stops login rather than reporting a successful upgrade.
SQL (PostgreSQL, MySQL, SQLite) remains authoritative, with optional version-validated Redis snapshots — there is no custom-repository extension point. The guarded writes above (name-only update, conditional email verification, port.AdminGuardStore) are required parts of the port.UserRepository contract that the built-in SQL store implements, not optional capabilities with a fallback. Direct calls to the general repository Update method remain snapshot writes. Applications that change an email themselves must clear its verification state in the same write.
Schema migration deployment
Execute auth migrations explicitly before starting auth
traffic and workers. Use separate deployment credentials for DDL and runtime
credentials for ordinary operations. New never migrates. Startup always checks
database availability and rejects incompatible or incomplete recorded
history before workers start; structural drift needs deployment verification.
Structural verification and recovery reject retired auth objects that remain
after an incomplete drop or rename, including names from older catalog snapshots.
Do not replace published auth upgrades with an ORM-generated diff: data transformations and credential invariants belong to the reviewed migration. The initial baseline/adoption path does not rewrite credential material. Back up and review recovery before production changes. Restoring old auth data can restore revoked sessions or consumed tokens, so backup restoration also needs a credential/session invalidation procedure appropriate to the incident. Programmatic migration errors retain driver causes; redact those causes before logging them or exposing them outside the operator boundary.
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 andEnvironment). - 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
Storeis per-process; running multiple instances behind a load balancer needsWithRateLimitStorepointed 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
RequireOrgScopeorRequireActiveOrgScopeso the authorizedgoauth.OrgScopeis a required handler argument, pass that complete scope into repository methods, and includescope.OrgIDin 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:Refererleakage if the landing page loads third-party resources, and retention in browser history/autocomplete and server/proxy access logs. SetReferrer-Policy: no-referrer(orsame-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.
Organization permission boundaries
Fixed-role organization invitations also retain their issuer's authority as a live requirement. Creation, renewal and acceptance check that the issuer still exists and is not banned, then check current membership under transaction locks. The issuer's original session need not remain alive. Renewing an Owner invitation requires an Owner; renewal and acceptance reject an issuer who can no longer grant the proposed role. Delete and recreate that invitation under a currently authorized issuer. Email delivery occurs after the mutation transaction commits.
Org permissions are separately opt-in. Backend checks load the current user,
membership, role, definition and grants for the requested organization. Missing
membership remains org_member_not_found; denied role/grant access is
org_forbidden; infrastructure failures remain server errors. A supplied session
ID is reloaded and checked for ownership, expiry, revocation and required 2FA.
Trusted Go callers must supply authenticated identity and session attribution;
JSON cannot select the actor.
Protected owner access never bypasses last-owner, counter or session invariants.
Non-owners cannot grant/revoke ownership, edit their own role, reassign themselves,
or delegate permissions beyond their current effective grants. App administration
confers no automatic normal org access. Platform oversight is an explicit separate
path. Membership removal cascades its assignment, and pending invitation acceptance
rechecks the issuer and the role revision before its atomic claim.
Deleting business definitions applies the held-key delegation bound as well as
the reference guard. Authorized inspection of an ineligible target returns its
assignment metadata with no effective access; it does not authorize the target
or relax checks on the requesting actor. Role-linked invitations keep their
saved revisions: org_invite_role_changed requires review and recreation rather
than silently adopting changed grants.
Fixed-role changes reconcile persisted assignments even while org permission
enforcement is disabled, using the shared account-mutation guard. Fixed-role
removals acquire that guard before membership locks too, preserving the same
user-before-membership order as role changes and avoiding removal/demotion
deadlocks. When audit is enabled, org authorization audit events identify the
actor and organization and mark successful changes with success: true. They
use the configured durable audit pipeline.
Use ProtectOrgScope or ProtectActiveOrgScope for browser business routes,
and constrain every application-owned query by scope.OrgID. These include the
complete browser chain and obtain the scope's fixed membership role from the
same live permission check. The lower-level RequireOrgPermissionScope and
RequireActiveOrgPermissionScope include authentication and authorization only.
Access response flags are informational. The library cannot authorize individual
application resources without the application's own tenant/resource predicates.
For application HTTP routes, prefer Auth.Protect(pattern, handler), which combines the configured rate limiter with mandatory browser CSRF, session authentication and email-verification enforcement. Put ownership or role/permission checks inside it. RequireAuth alone only applies authentication and verification; see application routes.
Nested authentication from the same middleware instance reuses a private proof within the request while credentials, session context and identity are unchanged. Public context setters cannot create that proof. New requests authenticate again; this is not a cross-request authorization cache.
Email-address replacement
Enable with WithSecurity(SecurityConfig{AllowEmailChange: true}). The flag
defaults to false and requires a configured mailer when enabled.
Opt-in email replacement requires a live initiating session and current verification/MFA policy, both mailbox codes, and the current password when it is an enabled method for the account. Requests are tied to exact account, current email, session/context and a credential/authentication fingerprint. Password replacement and 2FA preference changes also cancel proofs inside their transactions, including kept-session and same-password changes. Restoring an old preference cannot revive an earlier proof.
Proofs use request/destination-separated inputs to the OTP HMAC primitives and constant-time comparison. Ten-minute total validity, five persisted failed guesses, three starts/account/hour, and three resends/mailbox with a one-minute cooldown bound abuse. Replacement preserves the hourly budget; resends preserve guesses and expiry. Route/IP limits remain additional controls.
Exhausting the attempt budget clears both proof hashes immediately. With the mail worker running, its minute-based maintenance pass clears expired hashes in batches of up to 100 requests, retaining the account's hourly start budget. Each cleanup write checks the request ID and expiry so it cannot erase a newer replacement. Expiry itself is enforced immediately, independently of cleanup.
Completion locks user, initiating session and request before rechecking expiry. The transaction updates only email/verification timestamps, consumes proofs, revokes all app/admin sessions and deletes account-bound verification tokens, including reset, set-password, deletion, 2FA and OAuth linking proofs. Public OAuth state without an account ID continues through the existing current-policy flow; provider subject links and memberships remain unchanged. Reset redemption additionally reasserts the proved address under the user lock.
The transaction enqueues code-free notices to both addresses independently of
audit enablement. The existing leased recovery worker delivers them at least
once with five attempts and its documented backoff/retention. Delivery cannot
undo a committed email change. Audit events are email.change.requested and
email.changed; enqueue failures retain the configured audit policy. Proof
emails are sent after commit, outside SQL locks. Failed rendering/delivery
conditionally cancels that exact proof version and preserves newer requests.
Target occupancy is checked only after both proofs, using the unique write and
email_change_unavailable. This workflow requires access to the existing
mailbox; there is no lost-mailbox recovery or admin bypass. Custom transports
supply browser protections and trusted actor/session attribution.
Redis security
Redis snapshots are encrypted/authenticated with a separately purpose-derived key and selected only after a live SQL cache-version lookup. Transactional/locking reads bypass caching; current user and permission state is never cached. SQL outages fail closed even with a warm cache. New cache_version metadata changes atomically with every library session/token mutation; legacy records bypass Redis. Enable caching only after every writer maintains versions. Redis failures may fall back to SQL for snapshots, fail closed for rate limits, and remain best-effort for 2FA notification counters. The challenge attempt cap stays in SQL. See Redis for secret rotation, capacity, ACLs, rollout and rollback.