Repository navigation
fix(session,password,basic)!: close the security audit findings of the session, password and HTTP Basic modules - #120
Merged
Conversation
…words The hashers trusted the parameters encoded in a stored hash, so whoever could write or import a hash controlled the cost of every verification against it: an argon2id hash with t=0, p=0, a missing parameter or an empty tag panicked inside golang.org/x/crypto, m=4294967295 made one verification try to allocate 4 TiB, a 1-byte tag accepted about one random password in 256, and a stored bcrypt cost of 31 asked for days of CPU. Verify now decodes and checks the stored hash before any hashing: RFC 9106 §3.1 ranges (t >= 1, p >= 1, m >= 8p), each of m, t, p exactly once and nothing else, version 19, salt and tag of 16 to 64 bytes, an encoded length bounded before parsing (ErrMalformedHash), and inclusive operator limits on the cost (ErrCostLimitExceeded): argon2id 256 MiB, 10 passes, 16 lanes by default (WithArgon2idLimits), bcrypt cost 14 (WithBCryptMaxCost), both raised to the hasher's own parameters. The constructors apply the same bounds and panic on static misconfiguration; NewBCryptHasher no longer clamps an out-of-range cost silently. bcrypt Verify compared only the first 72 bytes of a longer password while Hash refused it; NIST SP 800-63B-4 §3.1.1.2 requires the entire password to be verified, so both now return ErrPasswordTooLong. Empty passwords are refused by Hash (ErrEmptyPassword) and never accepted by Verify (RFC 8265 §4.1). NeedsRehash also flags a shorter salt and every hash Verify refuses. The hashers still apply no Unicode normalization: the package documentation shows an NFC decorator (SP 800-63B-4 SHOULD). Documentation fixed: the default bcrypt cost is 10, the argon2id defaults are the OWASP minimum rather than RFC 9106 §4's recommendation, and ErrMismatch is never returned by the shipped hashers. Audit: CORE-06, CORE-I7.
basic.Authenticator returned before verifying the password for unknown, disabled, locked and expired accounts — about 250 times faster than a known account with a wrong password at bcrypt cost 10 — so the response time told which accounts exist, contrary to its documentation (CWE-208). Every attempt now runs exactly one password verification: unknown users, loader failures and accounts without a password hash are verified against a dummy hash that the configured hasher makes at construction, and the lifecycle flags are read only after the verification. Every refusal is decided after that work; a failed Authenticate returns security.Anonymous() instead of its credential-bearing input. A loader implementing the new PasswordRehasher gets a stored hash that NeedsRehash upgraded after a successful login, so stored hashes converge on the hasher's parameters and keep costing what the dummy costs. Since any username now costs a full hash, an unbounded number of concurrent attempts could exhaust memory (19 MiB per default Argon2id verification). At most runtime.GOMAXPROCS(0) verifications run at once by default (WithMaxConcurrentVerifications, UnlimitedVerifications); an attempt that gets no slot before its context ends, or whose verification the context ended, fails with security.ErrUnavailable (503) whatever the account, so overload reveals nothing either. NewAuthenticator panics on a nil loader or hasher and when the hasher cannot make the dummy hash. The documentation now states the guarantees and their residuals (stored hashes at other parameters, the loader's own latency) and the operator's part: build the Authenticator once, bound request times, rate-limit failed attempts (NIST SP 800-63B-4 §3.2.2). Audit: CORE-03, CORE-12.
The Basic extractor forwarded control characters (NUL, CR LF, ANSI escape
sequences) and invalid UTF-8 in the user-id to the UserLoader — a log
injection vector into the application — and encoding/base64 silently
skipped CR LF inside the credentials. RFC 7617 §2: "The user-id and
password MUST NOT contain any control characters". The extractor now
checks the base64 alphabet before decoding, decodes with canonical
padding (RFC 4648 §4, §3.5), refuses invalid UTF-8 and every control
character (C0, DEL and C1, RFC 8264 §9.12) and bounds the credentials
(4096 base64 characters, 3072 bytes decoded), and accepts several spaces
after the scheme (RFC 9110 §11.4); "Basic" without credentials is
malformed. Refusals wrap ErrBadFormat and ErrInvalidCredentials and never
quote the input.
basic.Authenticator applies the same checks before any lookup — a form
login or an OAuth2 password grant building the value with basic.New gets
them too — and refuses an empty user-id (RFC 8265 §3.1), which the
extractor still lets through for custom authenticators taking API keys as
`-u :KEY`. Malformed input costs no verification: it is a property of the
input, not of an account.
Authentication.Name() returned the user-id as typed ("ALICE" for the
subject "alice", or a password typed in the username field) although Name
must be safe to log: it now returns the authenticated subject, and
"basic" before authentication.
Audit: CORE-11.
session.NewCodec accepted an empty or low-entropy key and derived the AES key with a bare SHA-256: an unset environment variable let anyone forge a cookie for any subject, and a passphrase key could be brute-forced offline. Keys must now be at least 32 random bytes (a single-byte buffer never filled is refused too) and are expanded with HKDF-SHA256 under a format label (RFC 5869). Cookies carried no AEAD associated data, so a cookie minted for "shop_session" opened as "admin_session", or for another purpose sharing the keys. Cookie format v2 writes a version byte in clear and binds every cookie to version, cookie name and an optional purpose (WithPurpose) with length-prefixed associated data (NIST SP 800-38D); Codec.Encode/Decode are no longer exported, so only the Manager seals, always bound. The unversioned format is refused: every existing session cookie is invalidated once (users sign in again, documented). Rotate moved the expiry to now + TTL, so periodic rotations kept a session alive forever, and only the expiry carried by the cookie was checked. A session now expires at the earlier of its ExpiresAt and CreatedAt + the reading Manager's TTL, on every read; Rotate keeps the creation time and deadline, and writes (Login, Touch, Rotate) never extend it — Touch refuses a session past its deadline instead of writing it with Max-Age 1, and a nil session instead of panicking. The optional WithValidator hook revokes stateless sessions server-side; a validator error wrapping security.ErrUnavailable surfaces as 503 through the Extractor instead of a silent logout. Numbers in Values decoded as float64 (1234567890123456789 came back as ...768): they now decode as json.Number, read with Session.GetInt64. The saml-sp example asserted float64/int64 on its SessionNotOnOrAfter value and would have silently stopped enforcing it: it now reads it with GetInt64 and ends the session when the value is present but unreadable. Audit: CORE-04, CORE-08, CORE-09, CORE-14.
The session cookie had no prefix, so a sibling host could plant a cookie of the same name that shadowed the user's own (cookie tossing, login CSRF), and misconfigurations produced cookies browsers silently refuse: a __Host- name with Domain and Path was emitted, an invalid name produced an empty Set-Cookie while Login reported success, an oversized session was written and dropped. The default cookie name is now __Host-session — Secure, Path=/, no Domain (draft-ietf-httpbis-rfc6265bis §4.1.3.2) — falling back to __Secure-session with a Domain or another Path and to session with WithSecure(false), whatever the order of the options. NewManager panics on a configuration browsers would refuse or that cannot work: a nil codec, a name that is not an RFC 6265 token, a relative, invalid or over-long path or domain, __Host-/__Secure- rules broken by the attributes (prefixes matched case-insensitively, §5.4), SameSite=None without Secure, a TTL that is not positive, a negative idle timeout. Login, Touch and Rotate refuse a cookie whose name and value exceed 4096 octets (§5.6) with ErrCookieTooLarge and write nothing. When a request carries several cookies of the session's name, only those that open and are live count: two valid sessions make the request ambiguous and it has none — a tossed cookie can deny a session, never swap it — and a validator that cannot check one of them ends the search with 503. Documentation: the store is AES-256-GCM (an AEAD), not "AES-GCM with HMAC"; the CSRF token is rendered into pages, so HttpOnly does not keep it from an XSS; VerifyCSRF must be called on every state-changing request (sign-in included) behind net/http's CrossOriginProtection; one key seals at most 2^32 cookies (NIST SP 800-38D §8.3). The opt-in SAML browser suites assert the new cookie name (make e2e-saml: 16/16 scenarios pass). Audit: CORE-I8.
The exported fields of a session.Session revealed its CSRF token and raw
ID through %+v and encoding/json — log/slog's JSON handler encodes nested
values with encoding/json — and basic and bearer authentications printed
the password, the bearer token and, once authenticated, the
application's user record (its password hash included).
session.Session now implements String, Format (every verb), LogValue and
a redacting MarshalJSON: the ID as its SHA-256 fingerprint (the
session.id_hash span attribute), the keys of its values but not the
values (tokens, personal data), the times, and never the CSRF token. The
cookie is sealed from an internal payload type, so the redaction applies
to printing and encoding only. session.Authentication, basic.Authentication
and bearer.Authentication print and log their name — the subject, or the
scheme label — their state and authorities only; their JSON was already
{} (unexported fields), now pinned by tests.
A verified bearer.Authentication kept its token for the rest of the
request, contrary to the Token() documentation: WithAuthenticated now
drops it, so every TokenVerifier building its result with
bearer.New(token).WithAuthenticated — jwtsec.BearerVerifier included —
returns a token-free value, and a failed bearer Authenticate returns
security.Anonymous() instead of its input.
Also fixes a flaky test of the previous commit: the cookie-size boundary
depends on the timestamps' RFC 3339 length, so it now runs on a fixed
clock.
Audit: CORE-15.
A request carrying several cookies of the session's name is refused when they hold different valid sessions (a tossed cookie). Two copies of the same session — the same cookie set once with and once without a Domain, say — are one session, not an ambiguity: refusing them signed the user out. Only valid sessions with different IDs make the request ambiguous. Audit: CORE-I8.
…eb sign-in The bearer-jwt example served an unauthenticated /token endpoint that minted an access token for anyone who asked, and the bearer-jwt, grpc-bearer and connectrpc-bearer examples printed a bearer token, valid one hour, to their logs. The demos now mint their token in-process, valid 15 minutes, write it to a file only the current user can read (os.CreateTemp), log the file's path and remove the file when they stop. bearer-jwt's newServer returns its minter, so its tests mint tokens directly — with the expired-token and wrong-scope challenge rows the transport change left for this one. The session-web sign-in form carried no CSRF token, so another site could sign a victim into the attacker's account (login CSRF), and it verified the password of known users only — 262 us against 66 ms at bcrypt cost 10 — so the response time told which accounts exist. The form now opens a session for itself and carries its CSRF token, which the sign-in checks; net/http's CrossOriginProtection refuses cross-origin posts; the credentials go through basic.Authenticator (one verification per attempt, malformed input refused); a session opened for the form is not signed in. The basic-http example challenged with Bearer although it accepts Basic credentials only; it now sends Basic realm="basic-http", charset="UTF-8". The HTTP servers of basic-http, bearer-jwt, connectrpc-bearer and session-web, and the README quick start, now bound every stage with read, write and idle timeouts (they had none, or a header timeout only). Audit: CORE-I9, CORE-03.
CLAUDE.md now states the invariants the previous changes rely on: every basic.Authenticator attempt spends exactly one password verification and reads the account state after it, password hashers check a stored hash before hashing, sessions are format-v2 sealed cookies (keys of at least 32 random bytes, bound to their name and purpose, default __Host-session, absolute lifetime never extended), and the credential-bearing types print without their secrets. The documentation of ambiguous session cookies now matches the behaviour: a request has no session when its cookies hold two different valid sessions; copies of one session do not count. The MIGRATION sections name the previous code and the legacy v0 packages rather than version numbers that read like releases. Audit: CORE-03, CORE-04, CORE-08, CORE-09, CORE-I8, CORE-15.
…face doubles The tests added by the previous changes now use the mockery mocks for the interfaces of the package under test: basic.UserLoader, PasswordUser and PasswordRehasher (a rehashing loader embeds the two loader mocks), bearer.TokenVerifier and session.PrincipalLoader. The expectations make the tests stricter: an attempt looks its user up exactly once, malformed credentials never reach the loader, and a stored hash is only rewritten when one is expected. Hand-written doubles remain where no generated mock can stand in: password.Hasher in the basic tests and security.Principal in the session tests (mockery generates in-package mocks, so another package's mocks exist only in that package's test binary), and the user records of the redaction tests, whose secrets must sit in fields fmt would print. Audit: CORE-03, CORE-11, CORE-12, CORE-15.
A request whose cookies of the session's name open as two or more valid sessions has no session again, even when they carry the same session ID. Copies of one session can differ — a copy planted before a sign-in that skipped Rotate carries the attacker's values under the victim's ID — and nothing tells which one the browser meant, so accepting same-ID copies let a planted copy win. This reverts the relaxation of the previous "copies of one session" change. Stale duplicates, left by a change of Domain or Path while the cookie keeps its name, now make every request of the browser sessionless until the old cookie expires: the Manager.Get documentation, MIGRATION and the security considerations tell how to clear each old variant once with Max-Age=0. Audit: CORE-I8.
… Manager The CSRF guidance told applications to protect their sign-in form with the token of a session opened with Login(ctx, c, nil) — on the Manager the Engine reads. That session has no subject: the Engine extracted it on every request, the PrincipalLoader refused it as its documentation asks, and every page of the browser, the sign-in form included, answered 401 for the cookie's lifetime. The obvious workaround, a loader mapping a session without subject to an anonymous or guest principal, made the Authenticator return an authenticated value that voter.Authenticated grants. The VerifyCSRF, Login and PrincipalLoader documentation, the package documentation, MIGRATION and the security considerations now open the form's session on a dedicated Manager — its own cookie name and purpose, a short TTL — that no Extractor reads, and say that a PrincipalLoader must refuse a session without a subject. The session-web example does so (cookie __Host-signin, cleared at sign-in), and a test runs the trap and the pattern through a session Engine. Audit: CORE-I8, CORE-I9.
…authentication Get returned the ExpiresAt carried by the cookie, later than the deadline the Manager enforces once its TTL was lowered; it now reports the enforced deadline, the earlier of that ExpiresAt and CreatedAt + TTL. The Session field documentation said the CSRF token is never exposed to JavaScript — it is rendered into pages, where an XSS reads it — and that LastAccessed is refreshed on every load, while the idle timeout keys off the value written by Login, Touch and Rotate. Like the basic and bearer authenticators, a failed session.Authenticator.Authenticate returns security.Anonymous() rather than its input, the pending value holding the decoded session. Tests pin the four-cookie bound of a request (four cookies of the session's name are inspected, five are not), and FuzzManagerGet sends two arbitrary cookies of the session's name: Get never panics, opens only sealed cookies, and two copies of one cookie never make a session. Audit: CORE-08, CORE-I8, CORE-15.
A hasher that cannot verify — a remote HSM or pepper service down — wraps security.ErrUnavailable, and the Authenticator answers it 503 before any account check. Nothing pinned it: answered 401 for unknown accounts and 503 for known ones, the status code would tell which accounts exist during the outage. Every attempt outcome now runs against such a hasher and must be classified unavailable. Audit: CORE-03, CORE-12.
Rows for a repeated p, p=256 and p=257 (which a wider parse would truncate to 1 lane) and a negative p: each is refused as a malformed hash, as the documentation promises for every parameter. Audit: CORE-06.
The Basic rules refused control characters only: invisible format characters — zero-width joiner and space, soft hyphen, bidirectional overrides and isolates, byte order mark, tags (Unicode Cf) — and the line and paragraph separators (Zl, Zp) still reached the UserLoader, so a user-id displaying as another one (with U+202E or U+200B inside) reached the application's lookups and logs. RFC 7617 §2.1 points to RFC 8265's OpaqueString and UsernameCasePreserved profiles, whose FreeformClass (RFC 8264) disallows the separators and the default-ignorable code points, the joiners outside a virama context included. The Extractor and the Authenticator (form logins) now refuse these characters in the user-id and in the password, with the same error as a control character. Accented letters, emoji without a joiner, variation selectors and no-break spaces are still accepted. Breaking: an account whose password contains such a character — an emoji sequence joined with U+200D, a pasted soft hyphen or byte order mark — can no longer sign in with Basic. MIGRATION, LIMITATIONS and the security considerations give the remedy: reset the password, and refuse these characters when a password is set. Audit: CORE-11.
session.Authenticator.Authenticate returns security.Anonymous() when it does not support the authentication it gets, but no test told that return value from its input: the test passed security.Anonymous() itself. It now passes a value of another type and asserts the anonymous result, with the generated PrincipalLoader mock (never called) in place of the hand-written loader. The breaking note on format and separator characters named passwords only: a user-id holding one — a Persian or Indic name written with ZERO WIDTH NON-JOINER U+200C or ZERO WIDTH JOINER U+200D — is refused too. CHANGELOG, MIGRATION, LIMITATIONS and the security considerations now say so, with its remedy: rename the account or give it an alias. Audit: CORE-11, CORE-15.
euskadi31
force-pushed
the
fix/audit-session-password-basic
branch
from
October 13, 2026 07:08
c115707 to
611137e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the following findings of the internal security audit of 2026-10-12 (commit c5c2ac3): CORE-03, CORE-04 (Medium), CORE-06, CORE-08, CORE-09, CORE-11, CORE-12, CORE-14, CORE-15 (the
session,basicandbearerpart) (Low), CORE-I7, CORE-I8, CORE-I9 (Informational).It implements maintainer decision 10 (sessions: strong keys, cookies bound to their name and purpose, an enforced absolute lifetime, exact numbers) and the decisions taken for this PR: the
__Host-sessiondefault with a literal duplicate-cookie rule (Q1), verify-side refusals with sentinels and documented remedies (Q2), no Unicode normalization inside the hashers (Q3), an optional server-side validator whose outages answer 503 (Q4), a default cap ofGOMAXPROCSconcurrent verifications (U5) and strict Basic parsing that leaves the empty user-id to custom authenticators (U6).Goal / Problem
basic.Authenticatoranswered unknown, disabled, locked and expired accounts before verifying the password: 267 µs against 62.5 ms for a known account with a wrong password at bcrypt cost 10, about 250×, although its godoc promised the opposite. The session-web example's form login had the same leak (262 µs against 66 ms).session.NewCodecaccepted any key, empty included, and derived the AES key with a bare SHA-256: an unset environment variable let anyone mint a cookie for any subject, and a passphrase key could be brute-forced offline from a single cookie.t=0,p=0, a missing parameter or an empty tag panicked insidegolang.org/x/crypto(fatal without a recovery handler);m=4294967295made one verification try to allocate 4 TiB; a 1-byte tag accepted 7 of 2000 random passwords; a stored bcrypt cost of 31 asked for days of CPU. Whoever writes or imports a hash controlled the cost of every login against it.Manager.Rotatemoved the expiry to now + TTL, so periodic rotations kept a 1-hour session alive at +4 h 35; only the expiry written in the cookie was checked, so a 30-day cookie stayed valid on a manager reconfigured with 15 minutes; a stolen cookie could not be revoked before its expiry.shop_sessionopened asadmin_session, and across applications sharing the keys.UserLoader(log injection and spoofing in the application's lookups and logs), its base64 decoding skipped CR LF, andName()returned the typed user-id (ALICE, or a password typed in the wrong field) instead of the authenticated subject.SessionNotOnOrAfteronce numbers were decoded exactly.%+vandencoding/jsonof asession.Sessionwrote its CSRF token, raw ID and values (log/slog's JSON handler encodes nested values that way);basicandbearerauthentications printed the password, the bearer token and, once authenticated, the application's user record with its password hash; a verifiedbearer.Authenticationkept its token; a failedAuthenticatereturned its input.Verifyaccepted any password whose first 72 bytes matched (NIST SP 800-63B-4 §3.1.1.2: "SHALL verify the entire submitted password") whileHashrefused such passwords; empty passwords were hashed and accepted; the docs misstated the default cost, presented the OWASP minimum as RFC 9106's recommendation and documented anErrMismatchno hasher returns.__Host-prefix, so a sibling host could plant a cookie of the same name that shadowed the user's (cookie tossing, login CSRF); a__Host-name withDomainandPathwas emitted although browsers reject it; an oversized session was emitted and dropped by browsers; an invalid name produced an emptySet-CookiewhileLoginreported success; the docs said the store was "AES-GCM with HMAC" and thatHttpOnlykept the CSRF token out of reach of an XSS./tokenendpoint minting access tokens for anyone; the bearer demos logged a 1-hour token; the session-web sign-in had no CSRF token (login CSRF); basic-http challenged withBearer; the HTTP servers had no read, write or idle timeouts.Design
Authenticatethat passesSupportsruns exactly onehasher.Verify: against the user's hash, or against a dummy hash the configured hasher makes at construction (unknown user, loader failure, account without a hash); the lifecycle flags are read after it. An optionalPasswordRehasherloader keeps stored hashes at the hasher's current parameters (a hash at other parameters costs another time). The session-web login goes throughbasic.Authenticator.MinKeySize), single-byte buffers refused,HKDF-SHA256(key, info = "…/session v2 AES-256-GCM")(RFC 5869); generation and rotation documented.m,t,pexactly once, salt and tag of 16–64 bytes, an encoded-length bound, and inclusive operator limits (Argon2idLimits: 256 MiB, 10 passes, 16 lanes;WithBCryptMaxCost: 14) →ErrMalformedHash/ErrCostLimitExceeded. Constructors panic on out-of-range configuration.ExpiresAt,CreatedAt+ the reading Manager's TTL) on every read and write, reported byGet;Rotatekeeps creation time and deadline;Touchrefuses an expired or nil session.WithValidatorfor server-side revocation; a validator error wrappingsecurity.ErrUnavailableanswers 503, never a silent logout.base64url(0x02 ‖ nonce ‖ AES-256-GCM)with AD = version ‖ len-prefixed cookie name ‖ len-prefixed purpose (WithPurpose);Codec.Encode/Decodeunexported; earlier cookies refused.Basic+ 1*SP, base64 alphabet checked before strict decoding with canonical padding, UTF-8, no control character (C0/DEL/C1, RFC 8264 §9.12), no format character (Unicode Cf: joiners, soft hyphen, bidirectional controls, BOM, tags) and no line or paragraph separator (Zl, Zp) — after RFC 8264's FreeformClass, the base of RFC 8265's OpaqueString that RFC 7617 §2.1 points to — all refused with one error, bounded before decoding; the same checks inbasic.Authenticator(form logins); the empty user-id refused by the Authenticator only (U6);Name()= subject once authenticated,basicbefore.runtime.GOMAXPROCS(0)verifications at once (WithMaxConcurrentVerifications,UnlimitedVerifications); an attempt whose context ends while it waits answersErrUnavailable(503), for known and unknown accounts alike; rate limiting documented (NIST SP 800-63B-4 §3.2.2).UseNumber→json.Number,Session.GetInt64; saml-sp fails closed on an unreadableSessionNotOnOrAfter.session.Session, thesession,basicandbearerauthentications:String,Format(every verb),LogValue, and a redactingMarshalJSONforSession(ID fingerprint, value keys, times).WithAuthenticateddrops the bearer token; failedAuthenticatereturnssecurity.Anonymous()(basic, bearer, session).ErrPasswordTooLong) on both sides;Hash("")refused (ErrEmptyPassword),Verifynever accepts an empty password;NeedsRehashflags shorter salts and refused hashes; docs corrected; Unicode normalization documented as a decorator (Q3, nox/text).__Host-sessionby default (__Secure-sessionwith aDomainor anotherPath,sessionwithWithSecure(false));NewManagerpanics on a configuration browsers refuse (name, path, domain, prefix rules case-insensitive,SameSite=NonewithoutSecure, TTL, idle timeout);ErrCookieTooLarge(name + value > 4096 bytes, rfc6265bis §5.6); a request whose cookies of the session's name open as two or more valid sessions — copies of one session included — has none (Q1 literal rule; remedy for stale Domain/Path duplicates documented); GCM 2^32 budget (NIST SP 800-38D §8.3) and CSRF enforcement documented, doc errors fixed./token, an in-process minter; the three bearer demos write a 15-minute RFC 9068 token to a 0600 file, log its path and remove it on shutdown; session-web: CSRF-protected sign-in with the form's session on a dedicated Manager (__Host-signin),CrossOriginProtection; basic-http:Basic realm="basic-http", charset="UTF-8"; read/write/idle timeouts in every touched example and the README quick start.New public API:
password.Argon2idLimits,DefaultArgon2idLimits,WithArgon2idLimits,Argon2idHasher.Limits,WithBCryptMaxCost,DefaultBCryptMaxCost,BCryptHasher.MaxCost,ErrCostLimitExceeded,ErrPasswordTooLong,ErrEmptyPassword;basic.PasswordRehasher,WithMaxConcurrentVerifications,UnlimitedVerifications;session.MinKeySize,WithPurpose,WithValidator,ErrCookieTooLarge,Session.GetInt64. No new dependency (HKDF fromcrypto/hkdf).Breaking change / migration
Each item has a MIGRATION.md section (before/after table plus steps):
__Host-sessionby default;NewManagerpanics on an invalid configuration;Codec.Encode/Decodeare gone;Rotatekeeps the deadline, a lowered TTL applies to issued cookies,Touchfails at the deadline; numbers come back asjson.Number(.(float64)assertions become fail-open — useGetInt64);ErrCostLimitExceeded) need raised limits before import; bcrypt passwords over 72 bytes and empty passwords are refused (a migration wrapper for hashes imported from truncating implementations is documented);basic: malformed credentials and an empty user-id are refused; an account whose password or user-id contains a format or separator character (an emoji sequence joined with U+200D, a pasted soft hyphen or BOM; a Persian or Indic name written with U+200C or U+200D) can no longer sign in with Basic — reset the password, rename the account or give it an alias, and refuse these characters when passwords and user-ids are chosen;Name()is the subject; concurrent verifications are capped;bearer.Authentication.Token()is empty once authenticated; failed authenticators returnsecurity.Anonymous().Tests
Verifyper attempt, dummy from the configured hasher, refusals hold whatever the hasher answers), a sleep-based lower bound that cannot flake, and real-hasher ratios (interleaved accounts, fastest of 9, bound 0.5; before: 0.0001) — stress-run and under load.FuzzArgon2idDecode(5 min, 68 M execs),FuzzExtractor(5 min, 67 M execs, then 90 s, 26 M execs with the format/separator property),FuzzManagerGet(90 s, 17 M execs): no failure; seeds run in everygo test.pduplicate and overflow, the cookie-count bound) are killed by new tests.MockUserLoader,MockPasswordUser,MockPasswordRehasher,MockTokenVerifier,MockPrincipalLoader). Hand-written doubles remain forpassword.Hasherinbasicandsecurity.Principalinsession(another package's in-package mocks are not in this test binary) and for the redaction tests' user records (the secret must sit in a fieldfmtwould print).make e2e-saml(Tales + Chrome, asserts__Host-session): 16 scenarios, 114 steps passed.FuzzManagerGet, anonymous failures insession.Authenticator, and — by the maintainer's decision — format and separator characters refused in Basic credentials.Checks
make generate sync build test lint check-moduleson Go 1.27.2 (golangci-lint v2.14.0): green, 0 issues,make syncchanged nothing. govulncheck: no vulnerability in any of the 25 modules — this machine's network blocks the govulncheck binary, so it ran with-db file://on a freshvuln.go.devsnapshot; CI runs it online.basicbearerpasswordcrypto/randand post-checkGenerateFromPasswordfailures)sessioncrypto/randfailures: unreachable)examples/basic-httpmainbinds a socket)examples/session-webexamples/saml-spexamples/bearer-jwtmain)examples/grpc-bearerexamples/connectrpc-bearerFollow-ups (out of scope)
Authenticatorwrapper.WithValidatoris the revocation hook.Notes for the maintainer
WithDomainorWithPathwhile the cookie keeps its name, clear each old variant once withMax-Age=0(MIGRATION step 5,Manager.Getgodoc).Manager(its own cookie name and purpose), never on the one behind the Engine; aPrincipalLoadermust refuse a session without a subject, never map it to a principal (it would be authenticated)./token, minter seam, 0600 token file).