Skip to content

Security

Max Partenfelder edited this page Oct 30, 2026 · 1 revision

Security

What the library and the image guarantee, and what they leave to you. Rules phrased as one-liners for wiring: Gotchas.

Guarantees

  • Cookie: HttpOnly, Secure (unless InsecureCookie), SameSite=Lax by default (None possible; Strict only for password-only stores: oidc.New and github.New refuse it, since their callback would arrive without the cookie; http.SameSiteDefaultMode, which sends no attribute, is refused), Path=/, host-only unless CookieDomain is set. The value is 32 random bytes; the backend key is its SHA-256. Session values are AES-256-GCM sealed with a key derived per session (HKDF-SHA256) from EncryptionKey, bound to the session ID and key. There is no signing key to leak.
  • Sessions: the ID is rotated at every completed login (the previous session is revoked); starting an OIDC or GitHub login keeps a logged-in session, so a cross-site link to a login route ends nothing. Logout and failed refreshes revoke server-side (and the GitHub token at GitHub, also at a GitHub re-login; not at expiry), the lifetime is absolute (MaximumAge from the login), a lifetime starts only on a session the store issued itself (a planted or expired cookie, or a handle kept past its expiry, cannot get one), a revoked session never regains an identity and refuses writes while its revocation marker lives (a full memory backend may evict the marker), and a refresh never overwrites a change saved while it ran. A password session is bound to the user's credentials: when the password hash or the second factor changes, every other session of the user ends at its next refresh (RefreshInterval) and the TOTP routes refuse it at once, so a session from before the change cannot outlive it or remove the new second factor. Revoking an unknown session ID stores nothing. A Secure host-only cookie on path / (the default) is named __Host- + CookieName, so a sibling subdomain cannot toss a same-named cookie that would be read first. Changing EncryptionKey logs everyone out; there is no key rotation list. Instances that share the EncryptionKey, the Redis KeyPrefix and the cookie name accept each other's sessions: give each instance its own key and prefix. A login pending its second factor is bound to the credentials its password step verified: a password reset or second-factor change in between ends it.
  • OAuth flows: state, nonce (OIDC) and the PKCE S256 verifier are random, stored server-side in the login session, valid for 10 minutes and single use. ID tokens are verified for signature, issuer, audience, expiry, nonce and subject. OIDC (including the endpoints discovery names), Keycloak and GitHub URLs must be https (loopback hosts excepted for tests). The library follows no redirect of these endpoints or a JWKS URL: a redirected call fails (redirect refused), because net/http would resend a token request's body (client secret, code, PKCE verifier) to the redirect's target, also over plain http. Discovery documents and key sets above 1 MiB are refused. The GitHub token is sent to the GitHub API host only. A token endpoint's error answer is recorded by its HTTP status and OAuth error code only, never its description or body; failed OIDC discovery requests and Keycloak Admin API answers by their status code only, and an ID token that does not verify by a fixed reason, never go-oidc's message (it quotes the token and the JWKS request's error).
  • Open redirects: return_to is accepted only as a same-origin relative path (starts with /, not // or /\, valid UTF-8, no control characters, at most 2048 bytes); anything else becomes /. Redirects after login always go to ExternalURL plus that path. Configured targets (LoginURL, ErrorURL, PostLogoutURL) are validated at startup.
  • Errors: responses carry fixed messages and stable codes. Errors of outbound HTTP calls name the method, the origin and a classified cause, never the path, query or bytes the server sent. Clients get a generic code and message; the reason (e.g. the missing Keycloak role) and a sanitized cause are recorded in the outcome and on the wide event, never in metric labels. Neither ever carries a credential or token.
  • Credentials: unknown users cost the same Argon2id verification as users with an Argon2id hash, and a stored hash nothing can verify answers like a wrong password at the same cost; API keys are compared as SHA-256 hashes in constant time without early exit; passwords are 1-128 bytes, default minimum length 12 characters. Each TOTP code is accepted once; wrong codes lock the second factor after MaximumAttempts in a row.
  • JWT: RS512 only, key ID is the RFC 7638 thumbprint, default lifetime 5 minutes, minted per request, never cached, exp required, never for an inactive identity. The verifier requires an audience and an https JWKS URL (loopback excepted), ignores JWKS keys that are not RSA signing keys with an RS*/PS* alg and a kid, stops trusting keys 24 h (MaximumKeyAge) after the last successful JWKS fetch, and its errors never repeat the JWKS answer. Backends must verify the signature, issuer and audience; do not trust headers a client could set (see the Traefik settings).
  • Impersonation: only session.Impersonator starts or stops it (SaveIdentity and SwapIdentity refuse to change the session's user or impersonator). It needs an identity that passes Actor (default admins) and is active and not impersonating, and a target that Load returns, passes Target (default non-admins), is active and is not the impersonator; it lasts at most MaximumDuration (default 1 h) and never extends the session. Every authorization check refuses an identity whose impersonator is inactive, and so do jwt.Signer and the image's checks; tokens name the impersonator in the act claim and every outcome and wide event carries both subjects.
  • Session storage: a Redis dump holds SHA-256 hashes of session IDs and AES-256-GCM ciphertext, not usable cookies. Without TLS the Redis connection is plain TCP: anyone on the path reads the Redis password and with it delete every session (logging users out); enable TLS (SESSION_REDIS_TLS_ENABLED in the binary, TLSConfig on your own client) unless Redis is on a network you trust. The binary always verifies the Redis certificate.

Known limits

No rate limiting (do it at Traefik, at least on the password login, register and TOTP routes), no retries, no idle timeout. The memory backend is for one replica at non-production scale; use Redis in production. It is not built for anonymous login floods: each login start stores a session, and about 100000 of them fill the default backend, after which every start evicts the session expiring soonest (a real user's, once the flood's are newer); rate-limit {prefix}/login (and the provider login routes) at the edge or use Redis. Logout CSRF is refused only by browsers that send Sec-Fetch-Site (all current ones); OIDC front-channel logout stays callable cross-site by design. A stale SaveIdentity from your own code racing a Guard refresh still overwrites it; use Session.SwapIdentity (saves only while the session still holds the identity you read) where that matters. Disabling TOTP needs the password but not a current second factor (in a session opened with the second factor or the one that enrolled it). The session in which your application changes a password ends too. With TOTP.Required, enrollment is trust-on-first-use: whoever first logs in with the password enrolls the second factor. There is no in-process cap on concurrent Argon2id hashing; rate-limit POST {prefix}/login and POST {prefix}/register (an anonymous Argon2id each) at the edge. A user whose stored hash is still a legacy one (LegacyVerifier, e.g. bcrypt) costs the legacy verification instead of Argon2id, so response times tell such an account from an unknown one until its first login upgrades the hash.

Clone this wiki locally