This document records the security posture of the library: the defaults it ships, the attacks it defends against, and the choices left to the operator.
Two password.Hasher implementations are shipped:
- Argon2id —
NewArgon2idHasher(params). The default profile (DefaultArgon2idParams) is the minimum of the OWASP Password Storage Cheat Sheet: memory 19 MiB, time 2, parallelism 1, 32-byte key, 16-byte salt. RFC 9106 §4 recommends stronger options (2 GiB, t=1, p=4; or 64 MiB, t=3, p=4 when memory is scarce): raise the parameters towards them when the hardware allows. - bcrypt —
NewBCryptHasher(cost), default cost 10 (bcrypt.DefaultCost, the OWASP minimum; lower costs are for tests). bcrypt processes at most 72 bytes: a longer password is refused byHashandVerify(ErrPasswordTooLong) rather than verified on its first 72 bytes — NIST SP 800-63B-4 §3.1.1.2 requires the entire password to be verified. That caps passwords of multi-byte characters below the 64 characters SP 800-63B-4 asks verifiers to permit: prefer Argon2id.
Stored hashes are untrusted input. Verify decodes the parameters of the
stored hash and checks them before any hashing: argon2id version 19, each of
m, t, p exactly once, t ≥ 1, p ≥ 1, m ≥ 8p (RFC 9106 §3.1), salt
and tag of 16 to 64 bytes (library policy: RFC 9106 sets no salt minimum,
§4 calls 128-bit salts and tags sufficient) → ErrMalformedHash; a cost
above the hasher's limits → ErrCostLimitExceeded (argon2id: 256 MiB, 10
passes and 16 lanes by default, WithArgon2idLimits; bcrypt: cost 14,
WithBCryptMaxCost; both inclusive and raised to the hasher's own
parameters). A hash imported from an untrusted source or written by an
attacker can neither crash the process, exhaust its memory or CPU, nor
accept random passwords. Empty passwords are never hashed nor accepted
(RFC 8265 §4.1).
NeedsRehash lets a login flow transparently upgrade a stored hash when the
operator raises the cost factor. Call it after a successful Verify and
re-hash if it returns true.
A plain mismatch returns (false, nil) — only malformed or out-of-bounds
input, a password too long for the algorithm or context cancellation
produces an error. Never store or log the cleartext password.
Unicode. The hashers hash the password's bytes as given, so they verify
the hashes of systems that never normalized. NIST SP 800-63B-4 §3.1.1.2 says
verifiers SHOULD apply NFC to Unicode passwords (RFC 8265 §4.2 too): wrap
the hasher in the decorator of the password package documentation, at
enrollment and at login.
basic.Authenticator answers every refused attempt — unknown user, loader
error, account without a password, disabled/locked/expired account, password
mismatch — with security.ErrInvalidCredentials at the client boundary, and
spends the same work on each: exactly one password verification, against
the user's hash or against a dummy hash the configured hasher made at
construction; the account state is read only after the verification. The
detailed cause stays in the wrapped error chain for server-side telemetry
only. Do not mirror it in the HTTP/gRPC response.
What remains measurable, and how to close it:
- Stored hashes at other parameters cost another time than the dummy:
configure the hasher with the parameters of your stored hashes and
implement
basic.PasswordRehasheron your loader, which re-hashes a stored hash thatNeedsRehashafter a successful login. - The loader's own latency (a cache hit for known users, a slower miss) is the application's: make unknown and known lookups cost the same.
- A stored hash
Verifyrefuses (malformed, above the limits) fails without hashing — an operator data problem, not reachable by a client. - Under overload an attempt that gets no verification slot answers 503
(
ErrUnavailable) whatever the account, so overload reveals nothing.
Malformed credentials — control characters, format characters (Unicode Cf:
zero-width joiner and space, soft hyphen, bidirectional overrides, byte
order mark), line and paragraph separators, invalid UTF-8, more than 3072
bytes, an empty user-id — are refused before the loader runs, without a
verification and with one error: a property of the input, not of an
account. The extractor and the Authenticator apply the same rules, so a
form login that builds the value with basic.New is checked too, and such
a character never reaches the application's lookups and logs (log
injection, a user-id that displays as another one). RFC 8264's
FreeformClass, the base of RFC 8265's OpaqueString profile for passwords,
disallows them too (but for the zero-width joiner and non-joiner after a
virama, which this library refuses as well): refuse them when a password or
a user-id is chosen, or the account cannot sign in with Basic (reset the
password; rename the account or give it an alias — a Persian or Indic name
written with U+200C or U+200D, for instance).
Basic authentication runs a full password verification on every request,
and since any username now costs one, a flood of attempts costs the server
CPU and memory (19 MiB per default Argon2id verification). The
Authenticator therefore runs at most runtime.GOMAXPROCS(0) verifications
at once by default (WithMaxConcurrentVerifications); further attempts wait
for a slot until their request context ends, then answer 503. Hashing is
CPU-bound: more parallel verifications than cores add latency and memory,
not throughput. Bound the request time (http.TimeoutHandler): net/http
request contexts carry no deadline.
Limit failed attempts per account and per client (NIST SP 800-63B-4
§3.1.1.2, §3.2.2 — verifiers SHALL rate-limit failed attempts): the library
ships security.AttemptLimiter and its in-memory implementation
throttle.MemoryLimiter (see Attempt limiting): give
the Authenticator one with basic.WithAttemptLimiter. It keys the
attempts on the user-id lower-cased — basic.WithAttemptSubject when your
store merges other variants (accents, Unicode forms) — and on the client
address. Prefer exchanging Basic credentials once for a session or a token
over sending them on every request. Form logins and the OAuth2 password
grant's ResourceOwnerVerifier can reuse basic.Authenticator to get the
same protections.
jwtsec defends against the classic JWT attacks:
alg=none— rejected. The verifier parses with an explicit algorithm allowlist, so an unsigned token never reaches key resolution; an algorithm outside the list is refused withErrAlgorithmNotAllowed.- Algorithm confusion (HS256 forged with an RSA public key) — the
default allowlist is asymmetric only:
RS256/384/512,PS256/384/512,ES256/384/512,EdDSA. HMAC algorithms are not allowed by default; enable them withWithAllowedAlgorithmsonly when both ends share a symmetric secret and you understand the trade-off. Every verification key is bound to one algorithm (RFC 8725 §3.1): itsAlgorithmmust equal the token'salg— a key without one verifies nothing — and an asymmetric key must passPublicKey.Validate(RSA of at least 2048 bits, the algorithm's curve, a public key: a private key or ajose.JSONWebKeywrapper verifies nothing; an Ed25519 key must have a canonical y-coordinate, RFC 8032 §5.1.3, that is not one of a point of small order — forgeries verify against the all-zero key and the identity point; a y-coordinate of no point of the curve is not detected, and verifies nothing). Akidshared by several keys designates none. RSASSA-PSS signatures must use a salt of the hash size (RFC 7518 §3.5; go-jose alone accepts any). - Claim smuggling (RFC 7519 §4, §7.3, §10.1.1) — claims are decoded
case-sensitively, by go-jose's decoder, into
StandardClaimsand into the caller'sclaimsOutalike. A claims set whose member names repeat — exactly, through an escape, or under Unicode case folding ("Sub","EXP","ſub") — at any depth is refused, so no case-folding consumer (Go'sencoding/json, .NET'sOrdinalIgnoreCase, approximately) can read another value than the verifier did. So are invalid UTF-8, unpaired surrogate escapes, a payload that is not one JSON object, and a registered claim of the wrong JSON type —"nbf": nulldoes not disable the not-before check. - Oversized and malformed tokens — a token over
DefaultMaxTokenSize(16 KiB,WithMaxTokenSize) is refused before any decoding, and so is a JOSE header that decodes to more than 4 KiB, whatever the token bound: go-jose decodes the header into maps, 70 to 260 times its size depending on its shape, before checking the algorithm (about 1 MiB and 1 ms at the bound,BenchmarkVerifyHostileHeader), and the payload is only base64-decoded before the signature is verified; only the canonical compact serialization is accepted (unpadded base64url, no whitespace, zero pad bits), with a strict JSON header that uses neithercritnorb64(RFC 7515 §4.1.11, RFC 7797 §7: a JWT never usesb64: false). Values copied from a token into errors and span attributes are clipped to 128 bytes. Cap the transports as well:http.Server.MaxHeaderBytes(1 MiB by default) andgrpc.MaxHeaderListSize(16 MiB by default) of about 64 KiB. - Keys named or carried by the token — a header holding
jwk,x5c,jkuorx5u(RFC 7515 §4.1.2, §4.1.3, §4.1.5, §4.1.6), whatever its value, is refused withErrMalformedTokenbefore go-jose parses the header. go-jose parsesjwkandx5cbefore it checks the algorithm, the key or the signature; up to v4.1.5 it validated an embedded RSA private key there, at a cost cubic in its size, so one unauthenticated 16 KiB token cost minutes of CPU (GHSA-mgcc-cww5-fp2q). RFC 7515 §4 would let a recipient ignore these members: refusing them is a local policy — keys come from the configured key set only (RFC 7515 §6; RFC 9068 §4 for resource servers), and a URL in a token is never followed (RFC 8725 §3.10). Issuers that send them cannot be served: Cloud Foundry UAA and SAP BTP XSUAA putjkuin every token header.x5tandx5t#S256are accepted and ignored. - Time claims —
expis required unlessWithOptionalExpiry; the token is expired fromnow == exp + skewon (RFC 7519 §4.1.4: the current time MUST be beforeexp);nbfis inclusive; aniatin the future is refused. NumericDates outside 1970–9999 are refused, so a year-1exp(a zerotime.Time) cannot make a token immortal, and no signer can emit one. The skew is at mostMaxClockSkew(5 minutes).
The verifier also validates iss and aud when configured, and resolves
keys by kid against a JWKS provider (static or remote).
- Token confusion (RFC 8725 §3.11–§3.12) — the OAuth2 access tokens
NewOAuth2AccessTokenSignersigns are typedat+jwtand carry ajti(RFC 9068 §2.1, §2.2); OpenID Connect ID Tokens are typedJWT.BearerVerifier, the resource-server adapter, refuses at construction a verifier withoutWithIssuer,WithAudienceandWithType(TypeAccessToken), or withWithOptionalExpiry— RFC 9068 §4 requires the resource server to checktyp,iss,audandexp— so an ID Token, or an access token minted for another API, is refused. An authorization server that types its access tokensJWTneedsWithAccessTokenType("JWT")(and the verifier'sWithType("JWT")): such tokens are told apart from ID Tokens by their audience only, which is safe only when the resource server's audience is never theclient_idof a client of that issuer. Untyped access tokens are never accepted byBearerVerifier.NewVerifierkeeps these checks opt-in for other JWTs. - Client tokens versus resource-owner tokens (RFC 9700 §4.15) — a
client_credentialstoken'ssubis its client ID (RFC 9068 §2.2), so a client registered under a user's subject would get a token naming that user.StandardClaims.ClientIDcarriesclient_id, and the principal ofBearerVerifieris anAccessTokenPrincipalwhoseSubjectIsClient()reportssub == client_id: refuse resource-owner data when it is true. It is a refusal rule only — a user whose subject equals the ID of the client they use reports true as well — so grant client privileges byClientID()and scopes. Amazon Cognito's client-credentials tokens are classified alike; Keycloak (service-account UUID), Auth0 (…@clients), Okta and Microsoft Entra ID give client tokens subjects of their own and always report false. A token withoutclient_idreports false too: with an issuer that setssubto the client ID but omitsclient_id, the resource server cannot tell the two kinds apart and relies on disjoint client and user identifiers only. Check the principal's type with a checked assertion: another authenticator, or the anonymous fallback, gives another principal. - Remote key sets —
NewRemoteJWKSfetches throughoutbound: https only, publicly routable destinations judged at dial time, no redirect, no proxy, 10 s and 1 MiB. An internal JWKS host needsWithOutboundPolicy(exempt its subnet inAllowedPrefixes);WithHTTPClientis an explicit opt-out that keeps the 10 s / 1 MiB bounds and still refuses a response that arrived over http for an https URL. Whatever the client, the redirects it may follow (MaxRedirects, or the opt-out client's own policy) stay on the origin (scheme, host and port) of the JWK Set URL: a hop to another origin fails withoutbound.ErrRedirectNotAllowed, so another server's keys never verify tokens. The set is refreshed in the background after the TTL (5 minutes by default) and on a token with an unknownkid— at most once perMinKeySetRefreshInterval(10 s), one fetch shared by every caller, never under a lock, not aborted when a caller gives up; failed fetches back off up to 5 minutes while the cached set keeps serving, until it is older thanMaxKeySetStaleness(24 h). Each key is validated on its own and an unusable one ignored (private or symmetric material,useother thansig,key_opswithoutverify, unknownktyor curve, RSA outside 2048–4096 bits, analgthe key cannot verify); a valid set without usable keys replaces the previous one. An outage isErrKeySetUnavailable, which wrapssecurity.ErrUnavailable(503 / Unavailable), never an invalid token. The provider prints and reports its URL without userinfo or query. - Signing keys —
NewSignerandNewOAuth2AccessTokenSignerreturn an error for a key unfit for its algorithm (RSA under 2048 or over 4096 bits, another curve, a public key, an HMAC secret shorter than its hash); KMS and HSM keys (jose.OpaqueSigner) are checked through their public half. Every token carries its key'sKeyIDaskid(RFC 7515 §4.1.4) — HMAC tokens included, so HMAC keys rotate like the others. An opaque PS* signer is checked at every signature: one whose salt is not the hash size — go-jose'scryptosigner.Opaqueover an in-process key — makesSignfail withErrInvalidKey, so no token is issued that verifiers refuse.MarshalJWKSrefuses an RSA key over 4096 bits too. The 4096-bit cap is go-jose's since v4.1.6: a larger published key breaks its consumers — this library's remote key sets andoidc/clientignore it, go-jose'sJSONWebKeySet(go-oidc relying parties among others) refuses the whole JWK Set — so tokens signed with one would verify nowhere. The bound is the library's own: raising the process-widejose.MaxRSAModulusBitsdoes not move it. The RFC 9068 signer refuses claims missing a REQUIRED value — an emptyiss,sub,audorclient_id, a zeroiatorexp— so anIssuerResolvermust return a non-empty audience for JWT access tokens. - Token strings are not unique — an ECDSA signature has a second valid
form (RFC 7518 §3.4 has no low-S rule; refusing it would refuse half of
the legitimate tokens, and normalising this package's own signatures
would not remove the twin anyone can compute): never key a cache, replay
check or deny-list on the token string — use
jti, or accept only the string the store holds (WithActiveTokenCheck).
- PKCE —
authorization_coderequires PKCE underProfile20BCPandProfile21Draft, and of every client that is not confidential — public or untyped — under every profile (RFC 9700 §2.1.1); underProfile20it is optional for a confidential client unlessgrant.Config.RequirePKCEis set.S256is the only method accepted under BCP / 2.1;plainis tolerated only underProfile20. Both ends enforce the RFC 7636 syntax:/authorizeanswersinvalid_requestto acode_challengethat is not 43–128 unreserved characters (plain) or not the unpadded base64url SHA-256 digest (S256), and, for a code bound to acode_challenge,/tokenanswersinvalid_grantto acode_verifierthat is not 43–128 unreserved characters. The code is consumed whether the verifier is malformed or wrong. Well-formed verifiers are compared in constant time. Acode_verifiersent for a code issued withoutcode_challengeis refused withinvalid_grantunder every profile (RFC 9700 §4.8.2, the PKCE downgrade attack: an attacker stripscode_challengefrom its own authorization request and injects the code into a victim's session); a parameter without value counts as absent (RFC 6749 §3.1). The refusal burns the code but revokes nothing: a replay of a code redeemed without PKCE still revokes its tokens, whatevercode_verifierit carries. - Client policy — fail closed: a client may use only the grant types it
declares (
Client.GrantTypes, compared exactly; empty allows none) and request only the scopes it declares (Client.Scopes; empty allows none)./tokenchecks the grant type for every grant, custom ones included, before the grant runs, so a refused request consumes nothing, and/authorizechecks the grant of the response type (authorization_codeforcode,implicitfortoken) before the extension and theConsentFuncrun;client_credentialsrefuses every client that is not confidential (RFC 6749 §4.4), and its tokens'Subjectis the client ID (RFC 9068 §2.2) — a resource server reading the store tells them from a resource owner's byAccessToken.ClientID == AccessToken.Subject(or an emptyExtensionData), and keeps client IDs out of the user-subject namespace when clients choose them (RFC 9700 §4.15.1); a consent narrows the requested scope, never broadens it; a refresh token goes only to a client declaringrefresh_token(RFC 9700 §4.14.2), and a refresh issues an access token only within the client's current scope registration, so shrinking a registration takes effect at the next refresh. Refusals caused by an empty declaration reachOnErrorwithErrNoGrantTypesDeclared/ErrNoScopesDeclaredas cause. Unlike RFC 7591 §2, an empty grant-type list implies nothing, and scopes outside the registration are refused rather than ignored (OpenID Connect Core §3.1.2.1 says unknown scope values SHOULD be ignored). - Request parameters — the parameters each endpoint reads are
single-valued (RFC 6749 §3.1, §3.2): an empty occurrence is omitted, and
one included more than once with a value is refused — at
/authorizea repeatedclient_id,redirect_uriorresponse_modewith a bare400, any other withinvalid_request; at/token,/revokeand/introspectwithinvalid_request, before the grant or the lookup runs. Callbacks get the request as received: aConsentFuncor anExtensionreads the core's parameters from theAuthorizeRequest, never withr.FormValue, which an empty occurrence can mislead. - Scope values — every scope a client or a callback sends (the
scopeof/authorizeand/token,Consent.Scope,ExtensionGrant.Scope) is parsed strictly as RFC 6749 §3.3 tokens — printable ASCII but"and\(NQCHAR), separated by SP; runs of SP and leading or trailing SPs are tolerated, any other whitespace is not — at most 100 tokens (repetitions included) and 4096 bytes. Anything else isinvalid_scope(server_errorfor an extension), refused before the value is compared with anything. Comparisons are set lookups, so the work a request causes stays linear in its bounded size, and the server issues and stores scopes canonical: single-SP joined, duplicates dropped. A registered scope outside that syntax can never be requested; a scope the server stored itself is never refused for its syntax. - Refresh-token rotation — mandatory under
Profile20BCP(the default) andProfile21Draft, whatevergrant.Config.RotateRefreshTokenssays: every refresh exchange consumes the presented token and issues a new one in the same family (RFC 9700 §4.14.2). The profiles apply it to every client, stricter than RFC 9700 §2.2.2, which requires rotation — or sender-constrained refresh tokens, which this library does not issue — for public clients only. Under these profiles — and underProfile20whenRotateRefreshTokensis set —NewServerrefuses arefresh_tokengrant without aRefreshTokensgenerator, and the grant itself fails withserver_errorbefore minting anything when it cannot rotate. Re-use of an already-rotated token is treated as theft: the whole family is revoked (ErrRefreshTokenReused), with the guarantees described under Token-family revocation. Clients MUST keep the refresh token returned by each exchange (RFC 6749 §6) and must not refresh concurrently with the same token, or they lose the family.RefreshTTLis an inactivity expiry that every rotation restarts;grant.Config.RefreshFamilyTTLadds an absolute bound from the family's first issuance (RefreshToken.FamilyIssuedAt), which also ends a family a thief keeps alive by rotating a stolen token — zero, the default, sets none. UnderProfile20rotation is opt-in (RotateRefreshTokens) for confidential clients only: without it their refresh token stays reusable until it expires, while the refresh token of a client that is not confidential — public or untyped — always rotates (RFC 9700 §2.2.2, §4.14.2); arefresh_tokengrant without aRefreshTokensgenerator then fails their refresh withserver_error, whichNewServercannot detect. - Token storage — access tokens, refresh tokens, and authorization
codes are stored hashed only (HMAC-SHA256 with an empty key, via
oauth2.HashToken). The store never sees cleartext, so a database compromise does not yield usable tokens. Tokens carry ≥ 128 bits of entropy, so the unkeyed hash is preimage- and brute-force-resistant. - Codes are consumed before they are validated — a party holding a
leaked code can burn it (presenting it with a wrong verifier, or as
another client): it obtains nothing — client, issuer, redirect URI and
PKCE still bind the code — but the legitimate exchange then fails with
invalid_grantand the user authorizes again. This is deliberate: single use rests on one atomic consumption, validating first would need a non-consuming lookup in every store, and refusing a code that has leaked is the conservative outcome (RFC 6749 §4.1.2 requires refusing a reused code). Keep codes out of logs andRefererheaders, and short-lived (AuthorizeConfig.CodeTTL, at most 10 minutes: RFC 6749 §4.1.2 —AuthorizeHandlerpanics on a longer one). - Atomic single-use —
ConsumeAuthorizationCodeis atomic in everyStorageimplementation, and so are the token writes:SaveTokenPairandRotateRefreshTokenpersist a whole pair or nothing, gated by the token family (SQL transactions with row locks, Redis Lua scripts, a mutex in memory). Concurrent use of the same code or refresh token yields exactly one winner; the conformance suite checks it with 50 concurrent consumers of one code and 30 concurrent rotations of one token, and races revocations against rotation chains and issuance. - Authorization-code replay — exchanging a consumed code is refused with
invalid_grant, and the tokens its first exchange issued are revoked: access and refresh tokens, and every later rotation (RFC 6749 §4.1.2, §10.5; RFC 9700 §4.2.4). Only a replay that would itself have been accepted revokes: the code has not expired, and the client it was issued to presents it with the sameredirect_uriand PKCE verifier and may still useauthorization_code(OAuth 2.1 §4.1.3, §7.5.3). Anything else — another client, a missing or wrong verifier, an unknown code — is refused and revokes nothing: a publicclient_idis no secret, and revoking on any replay would let whoever holds a leaked code cut the client off. The grant derives the code's token family from the code before consuming it, so the link takes no storage and a replay racing the first exchange cannot leave its tokens active: one landing before that exchange persists its tokens makes it answerinvalid_grantand issue nothing; one landing after revokes them. The first outcome holds while the store keeps the family's revoked record (oauth2.FamilyRevocationRetentionat least), i.e. under the deployment requirements: an evicted Redis family key, or a SQL family row deleted before itsexpires_at, lets the in-flight exchange recreate the family active. Stores keep a consumed code, hashed and flagged, at least until it expires — Redis expires the key with the code, SQL rows stay untilPurgeExpired, the memory store keeps them for good — so a replay is recognized for the code's lifetime (AuthorizeConfig.CodeTTL, 10 minutes by default); a later one is refused like any replay, and revokes nothing. A failed revocation reachesgrant.Config.OnError, and the next proven replay retries it. A legitimate client retrying a lost response, or submitting a code twice, revokes its own tokens: a code MUST NOT be reused (RFC 6749 §4.1.2). Access tokens validated offline are out of reach, see Offline JWT access tokens. - Profiles —
Profile20BCP(the default) follows the OAuth 2.0 Security BCP andProfile21Draftthe OAuth 2.1 draft: both refuse the legacyimplicitandpasswordgrants, which onlyProfile20allows — opt-in, never by default. - Client authentication — one method per request (RFC 6749 §2.3): an
Authorizationheader with a bodyclient_secret, severalAuthorizationfields, or a repeatedclient_id/client_secretareinvalid_requestbefore any client is loaded, and a bodyclient_idnaming another client than the one authenticated is refused.client_secret_basic/client_secret_postcompare secrets in constant time and never accept an empty one. Basic form-decodes the user-id and password (RFC 6749 §2.3.1, Appendix B) after splitting them at the first colon, refuses an invalid escape as a malformed header, and answers an empty decoded password or a missingclient_idwith401 invalid_clientbefore loading the client, the same answer whether or not the client exists; a form with an empty or absentclient_secretis anonerequest, which only public clients may make. CustomSecretMatchers MUST NOT match an empty secret;DefaultClientnever matches an emptySecret. A public client has no secret: it authenticates withnone(RFC 7591 §2) and MUST use PKCE. Register as public a client that cannot keep its credentials confidential (RFC 6749 §2.1)./introspectanswersClientConfidentialcallers only: a public client (whatever the method,noneincluded) or an untyped client gets401 invalid_client. Every failed client authentication reads the same —401 invalid_client, "client authentication failed", the Basic challenge — whether the client is unknown, of another issuer, presented a wrong secret, used a method it may not use or a malformed credential: the answer neither confirms a client exists nor discloses its type and methods; the detail reachesOnErroronly. Two residual signals remain, both acceptable since client identifiers are not secret (RFC 6749 §2.2): the response time — an unknown client skipsSecretMatches, so a slow matcher (a password hash for human-chosen secrets) makes it ~100 ms faster: such a matcher runs a dummy verification for unknown clients — and/authorize, which tells an unknownclient_idfrom an unregisteredredirect_uriby design (RFC 6749 §4.1.2.1 asks it to inform the resource owner). - Client secrets and guessing — a client secret is a password the
server verifies on every
/token,/revokeand/introspectrequest: RFC 6749 §2.3.1 has the authorization server protect these endpoints against brute force, and RFC 7662 §4 warns that an un-throttled/introspectlets an attacker fish for valid tokens. The server does not throttle — every failed client authentication answers the same401 invalid_clientand reachesOnError— so rate-limit the three endpoints perclient_idand per source address in front of the handlers, and alert on the failures. Generate secrets with at least 128 bits of entropy (RFC 6749 §10.10: a guessing probability of at most 2^-128, 2^-160 recommended — 32 random bytes), out of reach of online guessing. At rest, keep a hash:DefaultClientholds its secret in cleartext — for tests, examples and static configurations — and compares SHA-256 digests in constant time, so its timing discloses neither the secret nor its length; a productionClientimplementsSecretMatcherover the stored SHA-256 of a generated secret, compared in constant time, or over a password hash for a human-chosen one (with the dummy verification above). - Token introspection — RFC 7662 §4 requires the authorization server
to authenticate the protected resources calling
/introspect. A public client cannot authenticate: itsclient_idships inside every copy of the app and RFC 6749 §2.2 forbids using it alone, and the authorization server MUST NOT rely on a public client's authentication — even with a secret — to identify it (RFC 6749 §2.3). Accepting one would let anyone read the validity and claims of a token they held — throughnone, or through Basic/Post with the client's registered secret — so/introspectrefuses every caller that is not a confidential client. By default, any authenticated confidential client may then introspect any access token of the request's issuer, and a refresh token only if it was issued to it: refresh tokens are meant for the authorization server and their client (RFC 6749 §1.5), and a resource server that ignorestoken_typecould accept one as a bearer token. RFC 7662 §4 adds that protected resources SHOULD be specifically authorized, and that when a token can be used only at certain resource servers the authorization server MUST determine whether it can be used at the caller: that decision isServerConfig.IntrospectionPolicy, which sees the authenticated caller and the token's metadata before anything is disclosed — and replaces the default, so refuse refresh tokens to resource servers there too. A withheld token and a failed policy answer the same bytes as an unknown token ({"active":false}), so the response discloses nothing (its latency may still differ); a policy failure is reported toOnError. - Token revocation —
/revokerevokes the caller's own tokens of the request's issuer: a refresh token with its whole family, an access token with its family too (RFC 7009 §2.1). A token issued to another client, or under another issuer, is left untouched and answered with the200of an invalid token (RFC 7009 §2.2) — a deliberate deviation from RFC 7009 §2.1, which refuses such a request with an error: the error would confirm to whoever holds a token that it is valid for another client. - Issuer binding (realms) — a subject is only meaningful within its
issuer (OIDC Core §16.15). Every endpoint resolves the issuer of the
request through the
IssuerResolverbefore it loads the client; the code and every token record it, and theauthorization_codeandrefresh_tokengrants refuse, withinvalid_grant, a code or refresh token issued under another issuer than the one resolved at/token— so a client registered in several realms cannot carry an authorization across them. A refused refresh consumes nothing; a code replayed at another issuer revokes nothing, since it could not have redeemed the code — while a consumed refresh token presented at another issuer still revokes its family, the reuse check coming first (RFC 9700 §4.14.2). Records written before issuers were recorded fail closed. The resolver is the realm boundary: it must return the same issuer for/authorizeand/tokenof a realm, and theConsentFuncmust authenticate the resource owner withinAuthorizeRequest.Issuer— a session of another realm is no authentication./introspectand/revokekeep the same boundary: at the issuer the request resolves to, a token issued under another issuer answers{"active":false}and is not revoked (the answer is 200 all the same), so a client registered in several realms can neither read nor revoke another realm's tokens, and anIntrospectionPolicyonly sees tokens of the request's issuer. A token recorded without issuer — written before tokens recorded one — is inactive at a non-empty issuer, but its client can still revoke it: revocation only takes away. - Clients and realms — one
ClientStorebehind several issuers makes every client known at every issuer: a client of realm A could send realm B's end-users to B's genuine login page and obtain their codes and tokens once they consent. A client implementingIssuerBoundClient(DefaultClient.IssuerValues) exists at its issuers only: elsewhere/authorizeanswers the bare 400 of an unknownclient_id, never redirected (RFC 6749 §4.1.2.1), and/token,/revokeand/introspectfail its authentication as any other (401 invalid_client, "client authentication failed"), whatever the secret — the same bytes in both cases, so realm B can neither be used by A's clients nor enumerate them or check their secrets. The binding is checked before the client's secret and redirect URI, and again on the client an authenticator returns. A multi-issuer deployment binds every client, or scopes itsClientStoreby issuer. - Authorization responses — a
response_modeother than the one the server answers in (queryfor the code flow,fragmentfor an enabled implicit flow) gets a bare 400, never redirected: the answer could not be encoded as the client expects (OIDC Core 1.0 incorporating errata set 2, §3.1.2.6). Redirected errors carry only the codes the authorization endpoint defines (RFC 6749 §4.1.2.1, OIDC Core §3.1.2.6); descriptions anderror_uris outside the RFC 6749 charset are left out, and the server's own descriptions never echo the request. An approval names its subject — non-empty, at most 255 bytes — or fails closed withserver_error. AConsentFuncrefuses with a protocol code throughConsent.Refusal; aConsentFuncerror is always aserver_error. A refusal's description and URI travel in the client's URL — browser history, logs,Referer— so they must never carry personal data. - Redirect URIs — a request's
redirect_urimust equal a registered one, byte for byte (RFC 6749 §3.1.2.3, RFC 9700 §2.1: simple string comparison, no pattern), with one exception RFC 8252 §7.3 makes mandatory: a registered loopback IP redirect URI (http://127.0.0.1orhttp://[::1]) accepts any port, the rest of the URI unchanged — native apps listen on an ephemeral port — and the code is bound to the URI requested.http://localhostgets no such variance (RFC 8252 §8.3 recommends the IP literal). The registered URI a request resolves to must passoauth2.ValidateRedirectURIwith the server's profile — absolute, no fragment, a host, no userinfo and a port within 1–65535 for http(s), a private-use scheme holding a "." (RFC 8252 §8.4, which also rules outjavascript:,data:andurn:ietf:wg:oauth:2.0:oob), a query naming no response parameter, and, underProfile20BCPandProfile21Draft, no http but the loopback IP literalshttp://127.0.0.1andhttp://[::1](RFC 9700 §2.6: authorization responses never travel unencrypted;localhostis refused too) — or the request gets a bare 400, never a redirect (RFC 6749 §3.1.2.4); run the same function, with the same profile, when registering clients. Responses keep the registered query byte for byte (RFC 6749 §3.1.2), and the implicit flow appends its fragment to a URI that has none. A response answered in the query ends with the fragment#_(RFC 9700 §4.1.3), so that browsers do not carry the fragment of the authorization request — chosen by whoever crafted the link — on to the client's page; clients parse the redirect as a URL. - Implicit tokens never name their client — a
client_credentialstoken names the client insub(RFC 9068 §2.2): the implicit flow refuses to mint an access token whose subject equals the client's ID — a redirectedserver_error, causeoauth2.ErrSubjectIsClientinOnError, nothing persisted — so thatsub == client_idmarks a token without resource owner exactly (RFC 9700 §4.15.1). Keep client IDs and subjects disjoint. - Open redirection — RFC 9700 §4.11.2: a registered client controlling
its redirect URI could send users an erroneous request (an invalid scope)
or a
prompt=noneone, and have the authorization server redirect them to its phishing page without anyone authenticating. SetAuthorizeConfig.TrustedRedirect: for a client it does not trust, every error detected before the resource owner authenticated — and aConsent.Refusalstating the request cannot complete without a user interface (login_required,interaction_required,account_selection_required,consent_required) — is a bare error page, never redirected. Without it, every registered client is trusted (RFC 6749 §4.1.2.1 redirects errors once the redirect URI is validated) — a deliberate deviation from the RFC 9700 §4.11.2 MUST ("The authorization server MUST always authenticate the user first … before redirecting the user"), even underProfile20BCPandProfile21Draft, whichTrustedRedirectcloses. It rests on how clients are registered: the library has no dynamic client registration (RFC 7591, the attacker's path in §4.11.2), so every client comes from the deployment's own registry, and OpenID Connect Core §3.1.2.6 expectsprompt=nonerefusals to be redirected. Once the resource owner authenticated, the code and an explicitaccess_deniedare redirected to every client. - Authorization endpoint pages — the
ConsentFuncrenders the login and consent pages, but every response of/authorizecarriesX-Frame-Options: DENY,Content-Security-Policy: frame-ancestors 'none'(added to a policy an outer handler set),Cache-Control: no-storeandReferrer-Policy: same-origin: the authorization server MUST prevent clickjacking (RFC 6749 §10.13, RFC 9700 §4.16), its pages and redirects carry codes and states, and noRefererleaks the request URL to another origin (RFC 9700 §4.2.4).same-origin, notno-referrer: underno-referrerbrowsers sendOrigin: nullon the consent page's own POST, which Origin-based CSRF checks then refuse. AConsentFuncthat must let a trusted origin frame its page replaces them onw.Header(). Browsers check framing on the final response only, soprompt=nonein an iframe still follows the redirect. The authorization endpoint MUST be protected against CSRF (RFC 6749 §10.12): protect the request carrying the resource owner's decision — a token bound to their session, ornet/http.CrossOriginProtectionasexamples/oauth2does — not the endpoint, to which clients may POST authorization requests. A redirect answering a POST is a303(RFC 9700 §4.12: only 303 unambiguously turns the POST, and the credentials it may carry, into a GET), never a307. - Failures are outages, not refusals — a
ClientStorethat fails is not an unknown client: answeringinvalid_clientwould tell a client its credentials are wrong (and push it to rotate them)./authorizeanswers a bare 500,/tokenand/introspectaserver_error,/revokea 503temporarily_unavailable(RFC 7009 §2.2.1), each reported toOnError; a store reports an unknown client with(nil, nil)or an error carryinginvalid_client. A token lookup that fails at/introspectanswers{"active":false}— a resource server fails closed (RFC 7662 §2.1) — and reachesOnError. - Request bounds — RFC 6749 bounds no parameter, and
net/httpalone reads 10 MB of form. Every endpoint bounds the query and the body it parses toServerConfig.MaxRequestBytes(64 KiB by default) —invalid_request, a bare 400 at/authorize, before any parameter is read; a declaredContent-Lengthover the bound is refused before the body is. The values the server keeps or echoes are bounded too: thestateechoed in every authorization response (at most 4096 bytes of VSCHAR, RFC 6749 Appendix A.5 — refused without echoing it), thenoncerecorded with the code (512 bytes of UTF-8 without NUL, for every request — so no store is handed a multi-megabyte value to persist and re-encode), the scope (100 tokens, 4096 bytes) and theclient_id(255 bytes: a longer one is unknown, never looked up). A 4 KiBstateof unreserved characters keeps the redirect URL within the usual 8 KiB request-line limits; percent-encoding triples every other VSCHAR, so a state made of them reaches 12 KiB ofLocation— and an authorization server behind a proxy whose response-header buffer is smaller (nginx'sproxy_buffer_sizedefaults to 4 KiB) needs that buffer raised. A body a middleware parses before the handlers (r.ParseForm,r.FormValue) is read withoutMaxRequestBytes: bound that middleware's reads too. - Mix-up defense (RFC 9207) — every authorization response, code and
implicit, success and redirected error, carries
iss, the issuer the request resolved to, and the RFC 8414 metadata setsauthorization_response_iss_parameter_supported: a client talking to several authorization servers — or to several realms of oneIssuerResolver— compares it with the issuer it sent the request to and aborts on a mismatch (RFC 9700 §4.4.2.1), the defense RFC 9700 prefers to distinct redirect URIs. The server validates the issuer on every request (RFC 8414 §2, RFC 9207 §2: https, a host, no user info, query or fragment, a valid port, the form Go'snet/urlwrites; http on a loopback host only withServerConfig.AllowInsecureLoopbackIssuer, for development — spellings RFC 3986 §6.2 holds equivalent butnet/urlkeeps, such as an upper-case host or:443, are accepted: use the RFC 3986 normal form, as relying parties normalizing the URL compareisswith it):NewServerrefuses an invalidStaticIssuer(oauth2.ErrInvalidIssuer), and anIssuerResolverthat fails or returns an invalid issuer makes the request an outage — a bare 500 at/authorize, never redirected and before any client is loaded,server_errorat/token,/introspectand the metadata,503at/revoke. No authorization response is ever sent withoutiss, and none goes to a client whose issuer binding could not be checked.
ServerConfig.Extension (oauth2.Extension) is trusted code — the
oidc/provider OpenID Provider, or the application's own — plugged into the
authorization-code flow. The server keeps every OAuth 2.0 check and runs it
first:
- Bounded authority. The extension only sees requests and exchanges the
core accepted (client, redirect URI, response mode and type, scope, PKCE;
client, issuer, redirect URI, verifier and expiry at
/token). It can narrow a granted scope, never broaden it, and add anid_tokenmember to a token response, never replaceaccess_token,token_type,expires_in,refresh_tokenorscope— the response members are typed fields, not a map. Callbacks get read-only copies of the validated request: aConsentFuncor extension hook that modifies its copy — the redirect URI, the state, the scope, the PKCE parameters, the nonce, the issuer, the client's identity — fails the authorization closed withserver_error(oauth2.ErrAuthorizeRequestModified) rather than having its rewrite either honored or silently ignored; a grant narrows only throughConsent.ScopeandExtensionGrant.Scope. - Grant context. What
CompleteAuthorizationrecords (ExtensionData: at most 4096 bytes of UTF-8 without NUL, so every store keeps it byte-exact) is persisted with the code and copied onto every token derived from it; the stores refuse a refresh-token rotation that would change it, or the issuer. It can only be read back through the record its own code or refresh token designates: it cannot be substituted across clients, issuers, codes or token families, and a refresh never rewrites the authentication event it rests on (OIDC Core §12.2). - No partial success.
ExtendTokenResponseruns after the tokens are minted and before their single atomic write: when it fails nothing is persisted — a redeemed code stays consumed and a retry is a replay, a presented refresh token stays usable — and itsid_tokenis dropped when the write fails afterwards. A replay of the code that revokes the family while the hook runs wins: the exchange in flight answersinvalid_grantwithout itsid_token. The hooks must therefore have no externally visible side effects. - Consumers of the context.
AccessToken.ExtensionDatais empty for tokens no authorization precedes —client_credentials, whoseSubjectis a client ID, and the legacypasswordgrant — and for records written before it existed. An endpoint serving end-user data from an access token (an OpenID Connect UserInfo endpoint) must require its extension's context and anIssuerequal to the issuer it serves, or it would serve the claims of whatever user shares a subject with a client ID. - Integration contracts.
prompt=noneforbids any login or consent UI (OIDC Core §3.1.2.3): a rawConsentFuncthat renders a page runs before any hook can stop it, so an OpenID provider owns theConsentFuncit hands the application.CompleteAuthorizationbinds the authentication event it records toConsent.Subjectand refuses a trusted result it did not define. The hooks see the raw/authorizeform — credentials included when a login form posts to it — and the raw access token: they must never log or persist either.
Every access and refresh token issued from one authorization shares a token
family; client_credentials and implicit tokens belong to none. Revoking
the family is how the server answers a replayed refresh token (RFC 9700
§4.14.2, RFC 6749 §10.4) and how /revoke honours RFC 7009 §2.1, which asks
the server to invalidate the access tokens of a revoked refresh token's
grant (and allows revoking the refresh tokens of a revoked access token).
Every Storage keeps one authoritative state per family — active or
revoked — that issuance, rotation and lookups go through, so a revocation
never has to find the family's tokens:
- Complete revocation. A revocation is complete once
RevokeRefreshFamilyreturnednil, or once a reuse was reported withoutoauth2.ErrTokenFamilyRevocationFailed. From then on no token of the family is usable through the store — access-token lookups fail withinvalid_grant, refresh tokens read consumed — and every issuance or rotation into the family fails without persisting anything, including one that raced the revocation: it is ordered either before the revocation, which then covers its tokens, or after it, and is refused. A lookup running concurrently with the revocation may still answer "active". - Before the first token. A family can be revoked before any of its
tokens exists; the revocation is kept for at least
oauth2.FamilyRevocationRetention(24 hours), and never for less than the lifetime of the family's tokens, so a late issuance is still refused. - Atomicity. An issuance or rotation applies all its writes or none, never a part of them. One the store refuses — a revoked family, a reuse, a malformed pair — persists no token and consumes nothing: a failed exchange no longer leaves an access token behind. A backend failure can leave the outcome unknown: when the acknowledgement of a commit is lost, the call fails although all its writes may have been applied, and a client retrying the refresh then presents a consumed token — a reuse (see "Lost responses look like theft" below).
- Idempotence and isolation. Revoking a revoked family changes nothing and succeeds; a revoked family never becomes active again. Revoking one family affects no other family and no token outside a family, and the empty family ID is refused.
- Failure. A revocation that cannot be recorded fails with
oauth2.ErrTokenFamilyRevocationFailed, and its outcome may be unknown (a lost acknowledgement): treat the family as active and retry, which idempotence makes safe./revokethen answers503 temporarily_unavailable— RFC 7009 §2.2.1: the client must assume the token still exists and may retry — since a200would tell the client the token can no longer be used (RFC 7009 §2.1). It revokes an access token's family before deleting the token, so a failed family revocation leaves everything in place for that retry. On the reuse path the store reportsErrRefreshTokenReusedjoined with the revocation failure, therefresh_tokengrant retries the revocation on a context detached from the request, and a second failure reachesgrant.Config.OnError; the client getsinvalid_granteither way. - Fail closed. A token whose family state is missing — expired,
evicted, deleted by a premature cleanup, or never written by a build
predating token families — is treated as revoked. This protects lookups
only: the next issuance into a family whose revoked state was lost records
it active again and revives its tokens, so a revoked family's state must
never be lost within its retention — no eviction, no early cleanup. A
consumed refresh token must not be lost either: one evicted from Redis,
or deleted from SQL before its
expires_at, reads as an unknown token when presented again —invalid_grant, revoking nothing — so a stolen token's reuse goes undetected. - Lost responses look like theft. When the response to a successful rotation is lost, the client retries with the presented token, which is now a reuse: the family is revoked and the user re-authenticates. This is inherent to rotation; there is no grace period. A Redis rotation resent by the client library after a lost reply is recognized, though, and does not count as a reuse.
These guarantees rest on deployment assumptions, detailed in the package
documentation of store/sql and
store/redis: every read reaches the
primary (no replica reads, no client-side caching), the store's PostgreSQL
transactions run at READ COMMITTED (it pins the level) and
MySQL tables use InnoDB, Redis is a standalone server or a Sentinel primary
(not Cluster) running 6.0 or later with maxmemory-policy noeviction, no
family row or key is deleted before it expires, and durability is the
backend's: a revocation acknowledged by a primary that fails before
persisting or replicating it can be lost in a failover. CheckDeployment
checks what the server reports of them — on MySQL the engine, the binary
key columns and a strict sql_mode (Migrate runs it), on Redis the
version, the eviction policy and cluster mode — and the Redis store's
New refuses a Cluster or Ring client. CI checks these
guarantees on SQLite and, in its "Store suites (real backends)" job
(make test-backends), on PostgreSQL 16, MySQL 8.0 and 8.4, MariaDB 11.4
(internal/interop/sqlstores) and Redis 7.4 (the conformance suite of
oauth2/store/redis). On other versions and engines they rest on the
locking semantics above.
The store's revocation reaches every token looked up in the store —
/introspect, a resource server calling LookupAccessToken — but not a
resource server that verifies JWT access tokens offline, from their
signature and claims (jwtsec.BearerVerifier): such a token stays valid
until its exp, whatever happened to its family — including the tokens
revoked after an authorization-code replay: deleting or flagging store
records never invalidates a signature. RFC 7009 §2.1 acknowledges such a
propagation delay and asks implementations to minimize it; its §3 leaves
immediate revocation of self-contained tokens to non-standardized backend
interaction, and suggests short-lived access tokens instead. To bound the
window:
- keep
grant.Config.AccessTTLshort when access tokens are JWTs; - give
BearerVerifierthat backend interaction,jwtsec.WithActiveTokenCheck: it runs after every other check, in ajwtsec.BearerVerifier.ActiveTokenCheckspan, and refuses the token (ErrTokenInactive, 401) unless the authorization server reports it active. With a store shared with the authorization server, look the token up byoauth2.HashToken(nil, token)withLookupAccessToken— aninvalid_grant*oauth2.Errormeans inactive, any other error means the check could not be made (wrapsecurity.ErrUnavailable: 503); otherwise call RFC 7662 introspection. Accept only a token found active, never deny-list token strings: an ECDSA token has a second valid form, which the store does not know and a deny-list would miss; - or issue opaque access tokens, which only the store can validate.
oidc/provider trusts the application's Interaction for who the end-user
is, never for the protocol rules:
- Requests are checked before any interaction. A repeated
client_id,redirect_uriorresponse_mode, or a missingredirect_uri, answers a bare400that is never redirected; request objects andregistrationare refused;nonce,claims,max_ageand hints are bounded; anid_token_hintmust be an ID Token this issuer issued to this client, signed by a key it publishes. - Every approval is re-checked. Before a code is issued, the provider
checks the approval against the request — realm,
prompt=loginandmax_agefreshness, designated end-user, essentialacr— and the authentication event itself (a subject of 1 to 255 printable ASCII characters, a time at most 5 s in the future, boundedacrandamr), and requiresopenidin the granted scope. A violation issues nothing. Freshness is compared at the second ofauth_time; an application that must re-authenticate onprompt=loginshows its login page itself. prompt=noneshows no UI:AuthorizeSilentlygets noResponseWriter, and an OpenID request served outsideProvider.AuthorizeHandleris refused before anyConsentFuncruns.- Realm-bound authentications. An authentication counts in the realm
(issuer) it was established in only. The realm is the issuer: where
tenancy is finer — one issuer, several organizations — the
Interactionmust also check the session's tenant againstRequest.Client. Separate issuers isolate sessions, not clients: with oneClientStore, bind every client to its issuers (oauth2.IssuerBoundClient, see "Clients and realms" above) — a tenant-A client could otherwise send tenant B's end-users to B's genuine login page and obtain their identity and B's tokens once they consent. UserInfo refuses a token whose client is no longer registered at the issuer. prompt=nonereveals no session. The provider's refusals — a session of another realm, of another end-user, a stale one, an unmet essentialacr— carry the sentinels' fixed descriptions: on the wire they read as no session at all, so a client cannot probe the browser. The reason reaches theErrorHookonly; anInteractionrefusing with its own description must not describe the session either.InteractionStartedAtmust come from server-side state bound to the authorization request, never from the session or the request parameters: a forged value makes a stale session look fresh. The provider bounds it to[ReceivedAt − 15 min, ReceivedAt + 5 s]and ignores it underprompt=none.- Offline access needs consent (OIDC Core §11):
offline_accessis dropped from every grant — OpenID or plain OAuth 2.0 — unless theDecisiongrants it (the end-user's consent, or the application's policy), and a refresh token issued to an OpenID grant without that consent fails closed. Wiregrant.Config.RefreshTokenScope = oidcprovider.ScopeOfflineAccess: only the scope is gated, and a plain OAuth 2.0 grant gets refresh tokens per that setting. - Token confusion (RFC 9068 §5, RFC 8725 §2.8, §3.11, §3.12): an ID
Token's audience is the client ID, and every code exchange and refresh
whose access-token audience is the requesting client's ID, or that of a
client registered at the issuer that declares
authorization_code, is refused (server_error) — the first tokens ofclient_credentialsandpasswordgrants are checked only through the exchanges of the same issuer, so keep everyIssuerResolveraudience apart from relying parties' client IDs; JWT access tokens are typedat+jwt. Resource servers require that type —jwtsec.BearerVerifierrefuses a verifier withoutWithType(TypeAccessToken)— so an ID Token is never accepted as an access token. - Keys. Key rings are validated on every use (RS256 required, one key
per algorithm and one algorithm per key, RSA of 2048 to 4096 bits,
curves bound to algorithms) and fail closed; private and symmetric keys
are never published. The keys of JWT access tokens belong in
KeyRing.Published: onejwks_uriserves both (RFC 9068 §4). - Claims and accounts. Only the claims the request and the end-user
allow are released, and a
ClaimsSourcecannot override a protocol claim — not even under a case variant (SUB,ſub,s.u.b,iß,ïss) or a#alias (sub#x,s#ub-fr,email#verified) that a relying party matching names case-insensitively (Go'sencoding/jsonamong them) or normalizing them to camelCase would read assuboriss. Other spellings of known claims (Email,Roles), names that are case variants of one another, invalid names and values whose member names would alias are dropped too; declare every application claim relying parties decide on inConfig.ClaimsSupported, and keep the claim names end-users choose apart from them (see oidc-provider.md).ErrUnknownSubjectstops an OpenID grant, or a plain OAuth 2.0 grantProvider.AuthorizeHandlerapproved, at its next exchange; other plain grants and issued access tokens are not checked, so disabling an end-user must also revoke their token families. - UserInfo serves personal data to bearer tokens of OpenID grants of the
request's issuer only, with
Cache-Control: no-store. Allow browser origins (CORS) deliberately. - Issuers. The issuer is an https URL without query or fragment
(
httponly for an explicit loopback development opt-in). Discovery and the JWK Set are cacheable (max-age=300): anIssuerResolvermust never derive the issuer from an unvalidatedHostheader. - The application's pages. Login and consent pages need CSRF tokens and
anti-framing headers (
X-Frame-Options: DENY,Content-Security-Policy: frame-ancestors 'none'), must validate the URL they return to after a login (open redirect), and must never redirect with307— it would replay the posted credentials (OIDC Core §16.22).examples/oidc-providerdoes all of this. - Errors. Refusals reach the
ErrorHookwith their cause; causes never carry a token, anid_token_hint, anonce, alogin_hintor a claim value.
saml/sp (package samlsp) consumes assertions from enterprise identity
providers (SP-initiated Web Browser SSO, Profiles §4.1). An assertion is a
bearer credential delivered through the browser, so the module trusts only
what the operator configured and refuses whatever it cannot read in exactly
one way. This section is the threat model; the
integration guide gives the exact trust tables, the identity
mapping, the deployment and rollover runbooks, and the test evidence.
- Explicit trust only. Each connection pins the IdP entity ID and its
signing certificates, imported from administrator-provided metadata
(
ParseIdentityProviderMetadata), fetched from the IdP's metadata URL (FetchIdentityProviderMetadata,MetadataSource) or configured directly. Importing is the act of trust: a signature embedded in the metadata is not verified, and an aggregate (EntitiesDescriptor) is only accepted with a pinned entity ID. Each imported certificate is trusted as a signing key by itself, without chain building: a signingKeyDescriptorcarrying several certificates (a chain), or a key value that is not its certificate's key, is refused, and a CA or intermediate certificate must never be listed, not even in a descriptor of its own — its key would sign any user in (Metadata Interoperability Profile §2.5.1; the §2.6.1 deviations are listed in saml-sp.md). Nothing in a response extends that trust: theIssuermust equal the connection's entity ID, and aKeyInfocertificate only selects one of the trusted certificates (it must equal it exactly). AKeyInfowithout certificate (aKeyNameorKeyValueonly, which Core §5.4.5 allows) selects nothing: it is cut from the verified bytes where no digest covers it, after both parsers agreed on its position, and the signature is then verified as one withoutKeyInfo. - What is verified is what is used. gosaml2 and goxmldsig verify the
signature and decode the identity from the verified canonical bytes; no
identity value is read from the raw document (only whether a
ProxyRestrictioncarries aCount, whose value is the verified one), so a comment injected in aNameID(the CVE-2017-11427 class) cannot truncate it. The Response envelope (Issuer,Destination,InResponseTo,IssueInstant) and theStatusare signed only when the Response is; unsigned, they only add refusals (guide). Before verification, a structural profile pins the singleAssertionas a child of theResponse, uniqueIDs and the placement of every signature, so signature-wrapping attacks have nothing to wrap (seeTestCompleteLogin_XMLSignatureWrapping). - No parser differential on attributes. The pre-check reads the raw
tree with etree, goxmldsig and gosaml2 decode with
encoding/xml, and the two disagree on attributes that share a local name: etree'sSelectAttrreturns the first in document order whatever its prefix,encoding/xmlthe last, after exclusive C14N has reordered them. An element carrying two attributes of one local name — whatever their prefixes, namespace declarations included — and a prefixed attribute named like a SAML or XML-DSig attribute (foo:Algorithm,foo:Destination) are therefore refused, and the pre-check reads unprefixed attributes only. Without this,Algorithm="…sha256" foo:Algorithm="…sha1"passed the allowlist while goxmldsig digested with SHA-1. Thexmlprefix is bound to the XML namespace, which definesxml:lang,xml:space,xml:baseandxml:idonly: any otherxml:attribute is refused, sinceencoding/xmlmatches attributes by local name and readxml:NotBeforeas theNotBeforethe pre-check never saw. - Signed Response or signed Assertion (Profiles §4.1.3.5/§4.1.4.5 as
amended by errata E93 and E26): a signed Response covers its single
assertion. The SP metadata omits
WantAssertionsSigned. - Algorithms. goxmldsig accepts SHA-1, inclusive C14N and more;
samlspenforces its own allowlist on every signature before any cryptography: RSA or ECDSA with SHA-256/384/512, SHA-256/384/512 digests, and exactly the enveloped-signature and exclusive C14N transforms, in that order; nods:Object(errata E91). SHA-1 (rsa-sha1,ecdsa-sha1, SHA-1 digests) is accepted only from a connection that setsInsecureAllowSHA1; inclusive C14N, HMAC and unknown algorithms never are. IdP keys are RSA of 2048 to 8192 bits or ECDSA P-256/384/521. ECDSA signature values are verified as ASN.1 DER, as goxmldsig implements them: an IdP emitting the XML Signature 1.1 rawr‖sencoding fails closed. The SP signs its AuthnRequests with RSA (rsa-sha256). - Encrypted assertions are refused (
ErrEncryptedAssertionUnsupported) and the SP publishes no encryption key: TLS protects the assertion in transit, and gosaml2's decryption code — the subject of several advisories — is never reached.
Some responses the schema allows are refused because two readers could
take different identities from them: several assertions, several
SubjectConfirmation or AuthnStatement elements, an assertion without
NameID, a Response without Issuer, or a condition the SP does not
understand (Core §2.5.1.1). Each refusal has a typed error. Configure the
IdP to send one assertion carrying a persistent NameID:
- AD FS sends no NameID unless a claim issuance rule of the relying
party trust issues one: add a rule (for example Transform an Incoming
Claim) whose outgoing claim type is Name ID with the Persistent
Identifier format, from an immutable source such as
objectGUID. - Microsoft Entra ID — the Unique User Identifier (Name ID) claim
defaults to the user principal name, which can be reassigned: prefer
user.objectidwith the persistent format. - Okta — set the Name ID format to Persistent and the application username to an immutable value.
- Keycloak — set the client's Name ID format to
persistentand enable Force name ID format; signing the documents, the assertions or both works (the pinned interoperability fixture signs both). - Google Workspace — the custom SAML app sends the primary email with the unspecified format by default; set the Name ID format to Persistent and map the Name ID to an immutable attribute (an employee ID, for instance), since email addresses can be renamed and reassigned.
- Shibboleth IdP — it sends transient NameIDs unless a persistent
NameID generator (computed or stored IDs, see
saml-nameid.propertiesandsaml-nameid.xml) is configured, and selects the format from the AuthnRequest's NameIDPolicy, the SP metadata and the relying party'snameIDFormatPrecedence. Without a persistent generator, a connection requesting the persistent format gets an error status (InvalidNameIDPolicy).
- Login CSRF by response injection.
BeginLoginbinds the transaction to the browser with a__Host-samlspcookie (Secure,HttpOnly,Path=/, noDomain): the prefix keeps sibling subdomains and network attackers from planting it. It isSameSite=Nonebecause the IdP's POST to the ACS is a cross-site navigation that would not carry aLaxorStrictcookie. A response is accepted only for the transaction of the same browser, and its signedSubjectConfirmationDatamust answer that transaction's request ID (errata E90). An attacker's own valid response posted into a victim's browser is therefore refused, whatever cookies a sibling host tossed (browser suite, BR3 and BR4). Some legacy browsers (Safari on iOS 12 and macOS 10.14, Chrome 51–66) mishandleSameSite=None: their logins fail withErrMissingBrowserBinding. - The ACS must be exempt from the application's CSRF protection: the
POST comes from the IdP's page. The binding cookie and
InResponseToare its CSRF defense. - RelayState is never a URL. It is a random handle; the return target
stays server-side in
LoginOptions.AppState, and the library never redirects. An application redirecting to its AppState after the login must accept local paths only, with an allowlist rather than a cleanup: one leading/and no//, RFC 3986 path and query characters only — no control character, white space,\or non-ASCII byte — and still a local path once percent-decoded. Browsers drop TAB, LF and CR inside a URL and read\as/:/\t/evil.exampleis//evil.example, another origin.safeReturninexamples/saml-spimplements this. - Unsolicited responses are refused: IdP-initiated SSO cannot be bound to a browser (Profiles §4.1.5, errata E90).
- Leaked in-flight requests. The binding proves that the browser
posting a response started the login, not that the IdP's page posted it.
The IdP redirect URL carries the request ID (in
SAMLRequest) and the RelayState handle: whoever learns it while the login is pending — browser history, a proxy or server log, a shared screen — can sign in at the IdP as herself, get the response to the victim's request and auto-post it from a page the victim visits; the victim's browser attaches itsSameSite=Nonecookie and ends up signed in as the attacker. The same page can post a forged response that burns the victim's login (see Single use).Connection.ResponseOrigins(opt-in) closes both: the request's singleOriginheader (Fetch §3.2) — or, from a browser that sends none, itsReferer's origin — must be one of the IdP's origins, before the transaction is touched (ErrOriginMismatch). It is off by default because browsers sendOrigin: null, which an attacker's page can produce too and which is therefore refused, from a page whose Referrer-Policy isno-referrer(orsame-origin, cross-origin), from a sandboxed page, and after any cross-origin redirect before the POST. Keycloak's default realm sendsReferrer-Policy: no-referrerfrom its POST page; a realm header ofstrict-origin-when-cross-originmakes browsers send its origin (Keycloak live run, K8). Without the option, keepTransactionTTLas short as the IdP interaction allows, never log full IdP redirect URLs, and show the signed-in account after a login. - Single use.
CompleteLoginconsumes the transaction atomically — one winner across instances — once the response passed the browser binding, the transaction checks, the status and the ResponseInResponseTo, and before any signature verification: a login allows one verification, and a response refused from then on ends it (ErrTransactionConsumedfor any later POST; the span carriessamlsp.transaction_consumed). Only the browser holding the binding cookie gets that far. The assertion then goes to the replay cache (Profiles §4.1.4.5), keyed by a digest of the connection, the issuer and the assertion ID and retained until the assertion can no longer be accepted (the earlier ofSubjectConfirmationData NotOnOrAfter+ skew and the transaction expiry, plus one minute). Both stores must be shared by every instance, and read from the primary. - Transaction integrity. Each stored transaction carries an
HMAC-SHA256 under a server-held key (
Config.TransactionKeys, required) over its ID, browser binding, payload and expiry, verified in constant time before any stored field is used: a writer of the store can neither rebind a pending login to another browser or request nor relax itsForceAuthnor requested context (ErrTransactionIntegrity). The stores are trusted for availability and single consumption only: give the SP least-privilege access — a Redis ACL limited to its key prefix, a database user limited to its two tables. The keys are the records' only integrity protection: use keys distinct from the OpenID Connect client's and the session's, and replace a leaked one at once. BeginLoginwrites to the store without authentication: rate-limit the login entry point, and the ACS per client too — a POST that passes the binding check costs one verification.- Gate login initiation. A page of another site can send the user's
browser to the login entry point (forced login initiation, a form of
login CSRF): with an IdP session alive, the user is logged in, or
switched to another session, without asking. Through a connection whose
IdP the attacker controls, typically her own tenant, the victim ends up
in the attacker's account. The browser binding cannot tell: the victim's
browser started that login. Start logins only from same-origin
navigations — Fetch Metadata's
Sec-Fetch-Siteissame-originornone(typed URL, bookmark); refusecross-siteandsame-site— or from a CSRF-protected POST.examples/saml-spdoes the former and also refuses a request without the header, which a login forced in a browser without Fetch Metadata would be: those browsers (Safari and iOS before 16.4) cannot sign in to it, the header reaches only https (or loopback) sites (Fetch Metadata §3), andnonestill admits a login forced through a link opened from another application. The browser suite (BR8) shows the refusals (guide). - The ACS location is checked (Core §3.2.2, Bindings §3.5.5.2): the
request must have been received at the connection's ACS URL — the same
escaped path, the same host (
r.Host, never a forwarding header), no query string — andDestination(required on a signed Response) andRecipientmust equal it. Behind a proxy that rewritesHost, forward the original, or passCompleteLogina copy of the request whoseHostcomes from the trusted proxy's header. A SuccessResponsemust carryInResponseTo, equal to the request ID.
ClockSkew(3 minutes by default, 5 at most; errata E92) applies to every bound: an assertion is valid fromNotBefore − skewand untilNotOnOrAfter + skew, exclusive.IssueInstants must lie between the transaction start − skew and now + skew;ForceAuthnrequires anAuthnInstantafter the transaction start − skew.- goxmldsig checks the IdP certificate validity with gosaml2's single
clock, which
samlspsets tonow − ClockSkewso that gosaml2's own time checks are never stricter than those ofsamlsp. An IdP certificate is therefore accepted untilNotAfter + skew, but only fromNotBefore + skew: a certificate whose validity starts at a key rollover is refused during the firstClockSkew. Import new IdP certificates ahead of their use (errata E68: any listed key may sign).
The IdP entity ID is a claim of the connection, not a global identity: in a
multi-tenant application, one tenant can configure an IdP whose entity ID
collides with another tenant's. Key external accounts on the connection ID
and the persistent NameID (with the issuer and the NameID qualifiers).
Never key them on a transient NameID or an email attribute, and never link
accounts by email automatically: an IdP vouching for an address says nothing
about the account another tenant's IdP vouches for. Encode the key without
ambiguity — a connection ID may contain any separator: examples/saml-sp
length-prefixes the connection ID, the issuer, the NameID qualifiers (absent
ones stand for the issuer and the SP entity ID), the format and the value.
MaxResponseSize (256 KiB by default, 16 KiB to 1 MiB) bounds the ACS
request body — or, when the application parsed the form before calling
CompleteLogin (Go's own limit is 10 MB), the SAMLResponse value itself.
The response then passes a streaming lexical gate before anything builds a
tree: at most 1 MiB, 450
elements (MaxResponseElements, sized from goxmldsig's 1000-node
canonicalization budget; gosaml2 verifies up to about 500), 4096 tokens,
depth 64, 16 attributes and 16 namespace declarations per element, 32
in-scope prefixes, 8 KiB attribute values; no DTD, entity or processing
instruction, and only namespace-well-formed names (Namespaces in XML 1.0
§3, §7: no empty prefix or local part, which encoding/xml would keep whole
in no namespace). Measured on an Apple M-series machine (Go 1.26): a typical
login costs about 1.1 ms and 1.1 MB; the most expensive response within the
default limits about 25–33 ms and 21–28 MiB, about 61–69 ms and 33–43 MiB at
the 1 MiB ceiling; inputs aimed at amplification are refused within 10 ms
and 9 MiB. When a signature carries no KeyInfo and the connection trusts
several certificates, each certificate tried re-runs the whole verification:
at most the three most recent valid ones are tried, the one that verified
the connection's last such signature first, which bounds that case to three
times the costs above — once per login, since the transaction is consumed
before the verification. The pre-check runs twice on a response whose
certificate-less KeyInfo it cuts, within the same limits and before any
store access. A response carrying hundreds of group
memberships exceeds the element cap (ErrResponseTooLarge): filter the
groups the IdP releases to the application rather than raising limits.
FetchIdentityProviderMetadata downloads an IdP's metadata once, and a
MetadataSource keeps it current for a ConnectionResolver. Both treat the
network as hostile:
- The URL is configuration — the application's, or a tenant
administrator's — and never a value read from a SAML message or a
request. It must be https (http only with
Outbound.AllowHTTP), without credentials or fragment. No response content triggers a fetch: an unknownKeyInfocertificate fails the login, it does not refresh the metadata, so an attacker cannot make the SP fetch. - Requests go through
outbound(see Outbound requests): publicly routable destinations only, judged on the address dialed, no redirect, no proxy, no cookie, no credentials, 10 s, and the smaller ofMaxSize(1 MiB) and the policy's body limit, counted after decompression. When the policy allows redirects, none may leave the configured URL's origin (scheme, host and port): keys can only come from that origin. A host or port thatoutboundalways refuses (127.1, a trailing dot, an underscore, port 0) fails at construction withErrInvalidConfig. Only a 200 answer is read; no error carries the response body. An on-premises IdP needs its subnet inOutbound.AllowedPrefixes; pin its host inCheckURL, and its private CA inRootCAs. - Trust rests on TLS to the configured origin and on the pinned entity
ID (
MetadataSourceConfig.EntityIDis required): a document of another entity is refused.FetchOptions.EntityIDis optional, to discover a new IdP's entity ID: pin it whenever a fetch replaces keys already configured. Keys and endpoints change only through a valid document of that entity fetched from that URL, so an IdP key rollover is picked up at the next refresh — announce the new key in the metadata before signing with it. Signed metadata is not supported: the embedded signature is ignored, and a federation aggregate, whose trust comes from its signature, must not be used as a source. Where TLS to the IdP's metadata host is not trusted enough, import the metadata out of band. - Bounded refresh: at the document's
cacheDuration(one hour when absent, the shortest interval forPT0S) clamped to[MinRefresh, MaxRefresh](5 minutes, 24 hours by default), and when it expires; concurrent callers share one fetch, which runs detached from the caller that started it and is bounded by the outbound timeout; fetches are spaced byRetryInterval(one minute) after a failure or an early expiry. A due refresh never delays a login while a usable document is cached: it runs in the background, and only a caller with nothing usable waits for it, so a slow or hostile metadata endpoint cannot put its latency on the login path. While refreshes fail, the last good document is served for at mostMaxStale(7 days) after it was fetched and never past itsvalidUntil(Metadata §4.3.2, errata E94); then logins fail closed withErrMetadataExpired. Failures surface asErrMetadataUnavailable(theoutbounderrors stay matchable),ErrInvalidMetadataorErrMetadataExpired; a resolver returning them makesBeginLoginandCompleteLoginfail as infrastructure errors, not rejections.
XML signature verification and canonicalization are never implemented in this repository. They come from:
| Module | Minimum | Advisories considered |
|---|---|---|
github.com/russellhaering/goxmldsig |
v1.6.1 | GHSA-479m-364c-43vc (CVE-2026-33487, signature bypass, fixed in v1.6.0); GHSA-qhrp-hfff-vphr (quadratic memory in SignedInfo canonicalization, fixed in v1.6.1); older bypasses (CVE-2020-15216) |
github.com/russellhaering/gosaml2 |
v0.12.0 | GHSA-66r8-42f7-4q74 (allocation amplification in the round-trip validation, fixed in v0.12.0); GHSA-5hj8-q7fv-qrj6 and GHSA-hwqm-qvj9-4jr2 (decryption panics: unreachable, encrypted content is refused first); GHSA-pcgw-qcv5-h8ch and GHSA-vv4x-5gvr-chh8 (unsigned logout messages: Single Logout is not used); GHSA-6gc3-crp7-25w5 (CVE-2023-26483, DEFLATE bomb: POST bodies are never inflated, and gosaml2's own limit is set to one byte) |
github.com/beevik/etree |
v1.8.1 | GHSA-qm2r-qg5g-crr3 (stack overflow on deep nesting, also bounded by the depth cap), GHSA-2mj8-7fmf-pr7x, GHSA-f965-f62m-c42m, GHSA-x27v-cqwr-28jx — all fixed by v1.7.1 |
github.com/mattermost/xml-roundtrip-validator |
v0.1.0 | none (used by gosaml2 to refuse documents encoding/xml does not round-trip) |
gosaml2 is maintained, verifies before it decodes, and decodes from the
verified bytes; samlsp uses it only to encode AuthnRequests and to verify
responses, never its RetrieveAssertionInfo (which reports audience and
time problems as warnings). github.com/crewjam/saml was not chosen: it had
critical signature bypasses (CVE-2020-27846, and CVE-2022-41912 with several
assertions) and is less actively maintained; it serves only as the
independent IdP of the private interoperability tests. A future goxmldsig or
gosaml2 upgrade must keep TestGosaml2TraversalBudgetIsPinned green: the
element cap depends on goxmldsig's traversal budget.
session issues a stateless encrypted cookie — there is no server-side
session store to compromise or scale.
- Confidentiality + integrity — the cookie payload is sealed with AES-256-GCM (AEAD): tampering fails decryption, it is not merely detected.
- Keys —
NewCodecrequires keys of at least 32 bytes from a cryptographic source (openssl rand -base64 32,crypto/rand) and refuses shorter keys and buffers that were never filled; it cannot tell a random key from a 32-character passphrase, so never use one. Each key is expanded with HKDF-SHA256 under a label of its own (RFC 5869), so a secret reused elsewhere never yields the same AES key. - Binding — the AEAD associated data binds every cookie to the format
version, the cookie name and an optional purpose (
WithPurpose): a cookie minted for another cookie name, application or path that shares the keys never opens (NIST SP 800-38D). - Key rotation — the
Codecaccepts an ordered key list. New cookies are sealed with the first key; decryption is attempted against every key, so a key can be retired gracefully, andTouch/Rotatere-seal a cookie with the active key. - Absolute lifetime — a session expires at the earlier of the
ExpiresAtits cookie carries andCreatedAt + TTLof the readingManager: lowering the TTL applies to issued cookies at once, raising it extends none, and neitherRotatenorTouchever extends a session. The idle timeout slides only whenTouchrewrites the cookie. - Revocation — the cookie is stateless, so
Logoutonly deletes the browser's copy. To revoke server-side — a stolen copy after logout, every session of a user after a password change — registerWithValidatoragainst a store of yours (a deny-list of session-ID fingerprints kept until the session's deadline, or a "valid since" time per user); cache its lookups. A validator error wrappingsecurity.ErrUnavailableanswers 503, so an outage of that store never signs everyone out. - Cookie attributes — defaults are conservative:
Secure=true,HttpOnly=true,SameSite=Lax, and the name__Host-session: a__Host-cookie is Secure, hasPath=/and noDomain, so a sibling host cannot plant a cookie that shadows it (cookie tossing, draft-ietf-httpbis-rfc6265bis §4.1.3.2). When the configuration cannot satisfy that prefix the default name falls back to__Secure-session(aDomainor anotherPath) orsession(WithSecure(false), local plain-HTTP development only).NewManagerrefuses at start-up a configuration browsers would refuse: an invalid name, path or domain, a__Host-/__Secure-name whose rules the attributes break (prefixes matched case-insensitively, like browsers do),SameSite=NonewithoutSecure. - Shadowing cookies — when a request carries several cookies of the
session's name and more than one opens as a valid session, it has no
session: a tossed cookie next to the user's own denies it (until it
expires). A browser without a session of its own carries the tossed
cookie alone and is signed into the planted session — login CSRF —:
with a name that is not
__Host-(aDomain, anotherPath), a sibling host can do that, so keep the default name wherever possible. Copies of one session count as several — they can differ, as a copy planted before a sign-in withoutRotatedoes — while expired or undecodable ones do not count. With the default__Host-name no other host can toss one at all. Stale duplicates have the same effect: after changingWithDomainorWithPathwhile the cookie keeps its name, browsers keep the cookie of the earlierDomainorPathnext to the new one, so clear each old variant once withMax-Age=0— aSet-Cookieof the same name with the oldDomainandPath(in Go,http.Cookie{…, MaxAge: -1}). - Cookie only — the
Managerreads the session from the request'sCookieheader field, throughhttpsec.Carrier.CookiesNamed, never from the URL nor from a header named like the cookie: a link carrying a session value cannot log a browser into another account. - Session fixation — call
Manager.Loginat sign-in: it mints a fresh session (new ID, new CSRF token, new lifetime) and replaces the previous one.Manager.Rotatemints a new ID and CSRF token within the same session — after a step-up — and keeps its creation time and deadline. The ID never appears raw in a span — only a SHA-256 fingerprint. - Values — numbers come back as
json.Number(read integers withSession.GetInt64): a 64-bit ID never loses precision through a float64. Treat a value that is present but unreadable as a refusal, not as absent. - CSRF — the synchronizer-token helper (
CSRFToken/VerifyCSRF) renders the session's secret masked anew on every call — a fresh random mask XORed with it, so that a compression side channel (BREACH) never sees the same secret twice — and compares the unmasked token in constant time; renderCSRFToken(s), never theSession.CSRFTokenfield, which does not verify. It is a check, not a policy: callVerifyCSRFon every state-changing request a cookie session authenticates, never change state on GET, and putnet/http'sCrossOriginProtection(Sec-Fetch-Site and Origin checks) in front of the handlers.SameSite=Laxdoes not stop same-site requests from a sibling subdomain. The cookie isHttpOnly, but the token is rendered into the page: an XSS reads it — no CSRF defense survives XSS. - Sign-in form — protect it too, against login CSRF, with the token of a
session opened for the form (
Loginwith a nil principal) on a dedicatedManager: its own cookie name and purpose, a short TTL (WithCookieName("__Host-signin"),WithPurpose("sign-in form"),WithTTL(15*time.Minute)), read by noExtractor. On theManagerbehind the Engine, the Engine would extract that subject-less session on every request and thePrincipalLoaderrefuse it: every page, the sign-in form included, would answer 401. Do not work around that by mapping a session without a subject to a principal — anonymous or guest: theAuthenticatorwould return an authenticated value, whichvoter.Authenticatedgrants. The session-web example shows the pattern. - Size — the whole session is JSON-encoded into the cookie, and browsers
ignore a cookie whose name and value exceed 4096 bytes (rfc6265bis §5.6):
Login,TouchandRotaterefuse it withErrCookieTooLargeinstead of losing the session silently. KeepValuessmall. - Key usage — nonces are random (96 bits), and NIST SP 800-38D §8.3
limits one key to 2^32 seals: every
Login,TouchandRotateis one. Rotate the active key well before that volume (about 50 days at 1 000 cookie writes per second), and do notTouchon every request.
oidc/client logs users in through external OpenID Providers. The
provider is trusted for what it asserts about its own users only; the
callback request, the browser and — in a SaaS — the provider's metadata
are attacker-influenced.
- Browser binding — a login is bound to the user agent that started
it:
Begindraws a 256-bit binding secret the user agent keeps (an HttpOnly__Host-cookie withStartLogin), and the callback must present it. The nonce and the PKCE verifier are HMAC-SHA256 values of that secret (OpenID Connect Core §15.5.2), so the transaction store holds only a hash of the state and of the binding: reading the store yields nothing that completes a login, and a stolen state alone cannot be replayed from another browser (login CSRF, RFC 9700 §4.7). - Store integrity — every transaction carries an integrity MAC over all
its fields, which
Completeverifies before using any stored field: a transaction altered in the store — a strippedmax_ageoracr_values, a rewrittenAppStateor redirect URI — fails its login (ErrTransactionIntegrity). The MAC key is HMAC-SHA256(transaction key,"oidcclient/integrity/v2"‖ binding secret): it needs both the binding secret — which the user agent of the login holds — and a server-held transaction key (Config.TransactionKeys) the store never sees, so an attacker who can write to the store cannot forge it even for a login it runs with its own browser. The store is trusted for availability and single consumption only. The transaction keys are secrets like a session key: 32 bytes or more from a cryptographic source, shared by every instance serving callbacks, never logged (they areoidcclient.Secretvalues, which never print, log nor encode). Rotate them in three steps — add the new key last on every instance, move it first, remove the old one onceTransactionTTLhas elapsed: the first key protects new transactions, every key is accepted. - Single use —
TransactionStore.Consumeremoves the transaction atomically, unknown first, then expired whatever the binding, then a binding mismatch, which keeps the transaction so that a leaked state presented from another browser cannot burn the victim's login. A replayed callback finds nothing and never reaches the token endpoint. An authorization response is not authenticated, though: whoever learns the state can send the victim's own browser — which presents the binding cookie on a top-level navigation — to the callback with it, and that request expires the cookie (and, with an error or a bogus code, consumes the transaction): the login fails and the user starts over. Keep the state out of logs —AuthRequestprints and logs without it. - Mix-up attacks (RFC 9700 §4.4) — give every connection its own
redirect URI, distinct from every other connection's (by scheme, host,
port, path or query parameter):
Completerefuses a response received on another URL than the connection's redirect URI, and a transaction of another connection, issuer, client, redirect URI or response mode. A provider advertising RFC 9207 — or declared to send it withConfig.RequireIssParameter— must also send theissparameter, compared with its issuer; a response carryingissfrom another provider is discarded (RFC 9207 §2.4). Never route callbacks by the state value. Distinct redirect URIs leave the gap RFC 9700 §4.4.2.2 names — an attacker registering, at the honest provider, a client with the redirect URI of its own connection, so that the honest response lands on the attacker's connection.RequireIssParametercloses it on the connection that receives the response: set it on every connection a tenant configures (the application's setting, never the tenant's), and work only with providers that sendiss. Behind a reverse proxy,HandleCallbackcompares the Host header the proxy forwards: preserve it, or giveCompletethe public URL. - ID Token — validated per OpenID Connect Core §3.1.3.7 before anything
is returned: RS256 — or the algorithm registered for the client, named in
Config.Algorithms(OpenID Connect Core §3.1.3.7 step 7) — asymmetric algorithms only (HMAC andnonerefused; PS256/384/512 with a salt as long as the hash, RFC 7518 §3.5; Ed25519 keys canonical and not of small order, RFC 8032 §5.1.3, RFC 8725 §3.4),typabsent orJWT(an RFC 9068at+jwtaccess token is refused), keys from the configured issuer'sjwks_urionly (jku,x5u,jwkandx5care refused, see below) through a bounded cache that refetches once, rate limited, for an unknown key, and never on a bad signature from a known one;iss,aud(further audiences only when trusted),azp,exp/iat/nbfwithin a bounded skew,nonce,auth_timeagainstmax_age,acragainst the requestedacr_values, andat_hashwhen present. Before go-jose parses it, the JOSE header must be strict JSON of at most 4 KiB withoutjku,x5u,jwkorx5c(OpenID Connect Core §2: ID Tokens SHOULD NOT use them): go-jose parsesjwkandx5cbefore any check, and up to v4.1.5 validated an embedded RSA private key at a cost cubic in its size, so a hostile provider could make each login cost hours of CPU (GHSA-mgcc-cww5-fp2q). RFC 7515 §4 would let a recipient ignore these members; refusing them is a local policy: providers that send them — Cloud Foundry UAA and SAP BTP XSUAA putjkuin their headers — are not supported. Every JSON document is decoded strictly: duplicate member names, two names that differ only by case, invalid UTF-8 and unpaired surrogates are refused, so no decoder can pick between two members. A lone member whose name differs from a known claim only by case (EMAIL_VERIFIED) is another claim, which OpenID Connect Core §2 and RFC 7519 §4 require ignoring — yetencoding/json, matching struct fields case-insensitively, reads it asemail_verified. An honest provider releasing attribute names its users choose can emit one. Decode claims withIdentity.DecodeClaims/UserInfo.Decodeor into a map, and read the validated values (Subject,Audience,AuthTime,ACR,AMR) from theIdentityfields, never again fromClaims. - Identity — the verified identity is the issuer and subject pair (Core §5.7). Link accounts on (connection or tenant, issuer, subject); never on email, phone or username, which another subject or provider can assert. For Microsoft Entra ID, configure the tenant's own issuer: the templated multi-tenant issuers are refused.
- Cookies — binding cookies are
__Host-(Secure, Path=/, no Domain: a sibling subdomain cannot plant one), HttpOnly, named after the state so parallel logins coexist, capped at four per browser, and deleted by the callback. The cap counts and expires only names of their shape — the prefix and 16 lowercase hex digits — never the application's own cookies sharing the prefix. SameSite is Lax for thequeryresponse mode — the callback is a top-level GET — and None forform_post, whose cross-site POST carries no Lax cookie; an opt-in headless-Chrome test checks both.InsecureCookiesdrops the prefix and Secure for local http development only:Newrefuses it unless the redirect URI is http — on a loopback host, the only http redirect URI accepted — and withform_post. - CSRF — exempt the callback route from CSRF middleware in
form_postmode: the cross-site POST is the protocol, the binding cookie its defense. Start logins on a user action from the application's own origin (a POST behindhttp.CrossOriginProtectionor a CSRF token) and rate limit them: each start writes a transaction, and a non-browser client passes the cross-origin protection.examples/oidc-clientlimits each client (an IPv4 address, an IPv6 /64) and each network (an IPv4 /24, an IPv6 /48) and bounds its in-memory store (memory.WithCapacity); a flood from enough networks still fills the store, which refuses new logins until transactions expire — limit at the edge as well. - Redirect targets —
AppStatecomes back from the store verbatim; validate it before using it as a redirect target. After a successful callback, create a fresh session and answer 303 to a clean URL, so the code leaves the browser history (RFC 9700 §4.3.1). - Account linking — linking an external identity to an account that
is already signed in is a login started on the victim's behalf if it can
be forged: on a hostile tenant's connection, a forged link start would
silently attach the attacker's identity to the victim's account. Start a
link only with a CSRF-protected POST after a recent re-authentication,
put a random value bound to the local session in
AppState, and afterCompletelink only when it matches the current session's value — never link on an identity whoseAppStatedoes not prove the link was started by that session. - Provider errors —
AuthorizationErrorandTokenErrorcarry the provider'serror,error_descriptionanderror_uri: untrusted text, theirError()prints the code only. Never render the description or the URI. The errors ofNew(discovery) may name the resolved address of an allowed destination: when tenants configure issuers, log them and show a generic message. The provider-controlled text an error echoes (an issuer, a media type, a redirect target, a JOSE header member, a query parameter name, the debug data of an HTTP/2 GOAWAY ending a response) is Go-quoted — control characters and invalid UTF-8 escaped — then cut to about 256 bytes. - Tokens — token and UserInfo requests never follow redirects
(discovery and JWK Set requests only within the origin requested);
UserInfosends an identity's access token only to the UserInfo endpoint of the connection that issued it, and returns claims only when theirsubis the identity's. Every credential is anoidcclient.Secret: everyfmtverb,slogandencoding/jsonrender itREDACTED— also when fmt reaches it by reflection through an unexported field, where it prints as an address — andReveal()is the only way to its value; a reflection dumper that follows pointers without calling methods (testify's failure diffs) prints it, andencoding/gobrefuses any value holding one.Identityencodes its claims andUserInfoits claims (personal data): never log them nested in another value. - Caveats — sending
acr_valuesmakesacrrequired, among the requested values; a public client (none) requires the provider to advertise S256.github.com/coreos/go-oidcwas evaluated and rejected: its verifier skips nonce, azp, iat,typ, auth_time and acr, and its key set refetches on every failure without rate limit (see the package documentation ofoidc/client).
oidc/provider and oidc/client validate the protocol — every rule their
feature matrices list (oidc-provider.md,
oidc-client.md, oidc-conformance.md).
What the protocol leaves open is policy, and stays with the application:
| Concern | The library validates | The application decides |
|---|---|---|
| Tenants and connections | issuer equality, a transaction bound to its connection, issuer, client and redirect URI, RFC 9207 iss; realms and issuer-bound clients (provider) |
which connections exist, routing a login start to one, distinct redirect URIs, which issuers tenants may configure |
| Identity and accounts | the (issuer, subject) pair, UserInfo sub equality |
account linking (on connection, issuer, subject), just-in-time provisioning, link flows bound to a session |
| Authentication strength | max_age/auth_time, acr among acr_values (client); freshness, essential acr (provider) |
the acr levels required, step-up and MFA, AMR policy, the login pages |
| Consent | released claims, a scope that only narrows, offline_access only with the decision's grant — prompt=consent for OpenID requests — or the application's policy, for every request |
the consent pages and records, the offline-access policy |
| Sessions | no session of another realm or end-user, no state revealed under prompt=none |
session lifetime, a fresh session after each login, sign-out (no logout specification is supported) |
| Account disabling | ErrUnknownSubject stops every grant at its next use (exchange, refresh, UserInfo) |
disabling accounts, revoking their token families for immediate effect |
| Keys | rings validated on every use, public keys only published; the client's bounded JWK Set cache | key custody (KMS), the rotation schedule, the response to a leaked key |
| Outbound requests | https and public destinations only by default | exemptions for on-premises providers (AllowedPrefixes, CheckURL, RootCAs) |
| Login transactions | browser binding, integrity, single use | the store's deployment, the transaction keys (generation, sharing, rotation), purging |
| Abuse and audit | nothing beyond the protocol's refusals | rate limiting of login starts and endpoints, audit logs (OnError sinks), monitoring |
Modules that fetch URLs from configuration or remote metadata (OpenID
Connect discovery, JWK Sets, token and UserInfo endpoints, SAML metadata)
use outbound.NewClient. In a multi-tenant deployment those URLs are
attacker-influenced — a tenant administrator types an issuer, a discovery
document names its own endpoints — so the client defends the server's own
network:
- Destinations are judged on the address actually connected to, in
net.Dialer.ControlContext, after DNS resolution: a name that resolves to a public address when vetted and to an internal one when dialed (DNS rebinding) is still refused. Refused by default: loopback, private (RFC 1918, RFC 4193 unique-local), shared (RFC 6598, CGNAT), link-local (including169.254.169.254and the other cloud metadata endpoints), multicast, unspecified, reserved, documentation and benchmarking space, and any scoped (zoned) address. IPv4-mapped IPv6 addresses and NAT64 (64:ff9b::/96) addresses are judged on the IPv4 address they reach; IPv6 is limited to2000::/3minus its special-purpose blocks (Teredo, 6to4, documentation), so an unknown or future reservation is refused, and an address with an ISATAP interface identifier (RFC 5214 §6.1), which an ISATAP router tunnels to the IPv4 address it embeds, is refused from any prefix. - URLs must be https (http only with
AllowHTTP, for development), carry no userinfo, and name an ASCII host: non-ASCII names (whichnet/httpwould map to another name before dialing), percent-encoded or underscore characters, empty labels and trailing dots are refused, so a host check inCheckURLsees the name actually dialed. - Redirects are not followed unless
MaxRedirectsallows it; every hop is re-checked, and https→http is always refused. Credential-bearing requests (token, UserInfo) never follow redirects.oidc/client's discovery and JWK Set requests,jwtsec.NewRemoteJWKS's JWK Set requests andsaml/sp's metadata requests, whose answers are trusted because of the origin they come from, never follow a redirect to another origin (scheme, host and port), whateverMaxRedirectsallows: after such a hop another server would get its keys trusted — for discovery, by merely writing the expected issuer. - Resources are bounded: one timeout for the whole exchange (10 s by default), a size cap on every body (1 MiB by default) counted after transparent decompression — a gzip bomb fails — and a 64 KiB header cap.
- No ambient authority: no proxy (an environment proxy would be judged instead of the destination), no cookie jar, TLS 1.2 or later with verified certificates.
Exemptions are explicit: AllowedPrefixes for an on-premises identity
provider's subnet (or outbound.LoopbackPrefixes() in tests). Any port
is allowed by default: restrict hosts in CheckURL, and ports and
specific public addresses in CheckAddr, which sees the numeric address
and port actually dialed. CheckURL sees a canonical port too — decimal
without leading zeros, absent for the scheme's default — so a port check
there holds against :022, but compare numbers, not strings. Only the
well-known NAT64 prefix is decoded: on a network translating a
network-specific prefix (RFC 6052), refuse that prefix in CheckAddr, or
its addresses reach the private IPv4 addresses they embed.
Host names ending in a number (127.1, 2130706433, 0x7f.1) are refused
before CheckURL: resolvers read them as IPv4 addresses, so a host check
would vet another name than the one dialed. A dial error never names the
resolved address and port, nor the resolver's address — a refused
destination, a refused connection, a timeout, a resolver failure keep only
their cause —, but an error after the connection (a reset, a TLS failure)
may, and every error tells whether a name is refused: when tenants supply
URLs ("test this connection"), log the error and show them a generic
message, or the error text becomes an oracle on the internal network.
httpsec.Carrier.Get/Values (and the gRPC and ConnectRPC carriers) read
header fields only: the Basic and Bearer extractors accept credentials from
the Authorization field alone, never from the URL query string (RFC 6750
§5.3: tokens in URLs leak through logs, Referer and browser history) nor
from a cookie named like the field (an ambient credential a cross-site
request would carry).
The Engine tries its extractors in order and the first one that finds a
credential wins (as in Spring Security's filter chain): a deliberate design,
not a fallback between schemes. Put the header-based extractors (bearer,
basic) before the session extractor: a browser attaches cookies to
cross-site requests by itself — SameSite=Lax still sends them on
top-level GET navigations — but never an Authorization header, so an
engine that accepts a cookie session authenticates requests an attacker's
page can trigger. Protect every state-changing endpoint reached with a
cookie session against CSRF, and prefer one engine per audience (browser
pages, APIs) so that an API never authenticates an ambient cookie.
security.AttemptLimiter is the online-guessing control of NIST SP
800-63B-4 §3.2.2 and the brute-force protection RFC 6749 §2.3.1 and §4.3.2
require of the OAuth 2.0 endpoints that check a secret. A verifier calls
Allow before it checks the credentials and Record with the outcome
after:
- Reserve, then settle — once per attempt. One
Allowper attempt, with one key: the limiter charges every budget it keeps for that key, the per-source one included.Allowcounts the attempt as failed untilRecordsettles it, so a burst of concurrent guesses cannot all pass the check before the first failure is known. Concurrent legitimate attempts hold reservations too: size the allowance above a client's concurrency, or authenticate once and carry a session or a token. A lostRecordkeeps the charge: it errs toward limiting. - Fail closed. A refusal wraps
ErrTooManyAttempts: HTTP 429 Too Many Requests withRetry-AfterandCache-Control: no-store(RFC 6585 §4), gRPC and ConnectResourceExhausted. A limiter that cannot decide (a shared store is down) returns an error wrappingErrUnavailable— 503 —, and any other error refuses the attempt too. - The key.
AttemptKeynames the credential type (Realm), the claimed identity (Subject) and the client address (Source). The subject is client input — even a password typed in the username field —: the key prints itREDACTED, andthrottlestores only a hash of it. Normalize the subject the way your account store does (case, Unicode form): every variant counted apart multiplies the allowance. - The source.
httpsec,grpcsecandconnectrpcsecstore the transport peer as the client address (security.ClientAddrFromContext). Behind a reverse proxy every request comes from the proxy: store the client address it forwards withsecurity.WithClientAddrin a middleware or interceptor that runs before the adapter — taken from the hop your trusted proxy appended (the right-mostX-Forwarded-Forentry it wrote, or RFC 7239Forwarded), never the left-most entry, which the client chooses. Outside the adapters (a form login callingAuthenticate, an OAuth 2.0 endpoint), read the address the same way: from the context first (security.ClientAddrFromContext), then from the transport peer (security.ParseClientAddr(r.RemoteAddr)).
throttle.MemoryLimiter charges every attempt to three budgets, checked
and charged together, a refusal charging none: per subject and source (10
failures, then one every 90 s), per subject from every source (100, then
one every 36 s — the per-account limit) and per source against every
subject (100, then one every 9 s — spraying). IPv6 sources count per /56
(RFC 6177), IPv4 per address; an unknown source only meets the per-subject
budget. A success clears its own (subject, source) failures and refunds its
own charge elsewhere — it does not reset the per-subject budget, which
other sources keep spending. The table holds at most MaxKeys entries
(100 000, about 12 MiB); when it is full the least throttled entry is
forgotten, never one of the attempt being charged.
Trade-offs to choose knowingly:
- The per-subject budget lets a distributed attacker lock an account out
while the attack lasts. Keep it for passwords (NIST requires a
per-account limit); turn it off (
Limit{Failures: throttle.Unlimited}) for credentials that cannot be guessed, such as OAuth 2.0 client secrets, whose client identifiers are public. Use one limiter per credential type. - The per-source budgets are shared by every client behind one IPv4
address or inside one IPv6 /56: a NAT, a proxy, providers that number
customers inside one /56, a NAT64 or SIIT translator (store the embedded
IPv4 address with
security.WithClientAddr). - An unknown source meets only the per-subject budget: with it off,
Allowrefuses the attempt as unavailable (503) — supply the client address. - It is a throttle, not the lock-out of NIST §3.2.2 ("no more than 100 …
by disabling that authenticator"), and it lives in one process. A global
or permanent limit needs an
AttemptLimiterover a shared store, or an account flag your loader reports locked.
Authorization follows Spring Security's voter model, and fails closed:
- The attributes are the policy. Each voter votes only on the
attributes whose family and value it supports:
HasRole("ADMIN")votes onsecurity.Role("ADMIN")and abstains onsecurity.Role("USER"), so a decision manager shared by several routes grants each route only what it asks for.voter.Authenticated()andvoter.Anonymous()vote only onsecurity.IsAuthenticated/security.IsAnonymous: list the state you require among the route's attributes. - Separate namespaces. A role is the authority
ROLE_<name>(security.RolePrefix), a scope the authorityscope:<name>(security.ScopePrefix, one scope per authority), anything else a plain authority matched verbatim byHasAuthority: a scope a client obtained can never pass for a role. - No requirement is ignored. A decision is denied when one of its
attributes is supported by no voter of the decision manager, when there
is no attribute, and when there is no voter — whatever the strategy, the
tie-break and the abstain fallback:
WithAbstainFallbackonly decides for custom voters that support an attribute and abstain. - Composites.
voter.Andgrants only when every inner voter grants — an inner voter whose attribute the route does not list makes it deny, so list one attribute per inner requirement.voter.Notnever turns the refusal of an unauthenticated principal into a grant:Not(HasRole("BANNED"))admits authenticated principals without the role, not anonymous callers. ARole("X")attribute is decided by every voter that supports it, negated ones included: a decision manager holdingNot(HasRole("X"))grants a[Role("X")]route to the authenticated principals without X, and next to a positiveHasRole("X")voter the affirmative strategy grants it to those with X too. Keep a negated policy in its own decision manager, used only by the routes it was written for. - Strategies. Affirmative grants on one satisfied attribute (OR);
unanimous requires every voting voter to grant (AND across voters), but a
multi-name voter such as
HasAnyRolegrants when the principal holds any of the requested names — use one voter per role, orvoter.And, for an AND of roles.
Error mappers return terse, code-first responses, classified once by
security.ErrorClass — joined errors included — so a refusal by one
authenticator is never masked by another one's lack of support, and an
outage never reads as a verdict on the credentials:
| Class | HTTP | WWW-Authenticate (scheme S, realm R) |
gRPC / ConnectRPC |
|---|---|---|---|
invalid_request (e.g. repeated Authorization) |
400 | Bearer: Bearer realm=R; + bearer token sent: error="invalid_request" |
InvalidArgument |
unavailable (ErrUnavailable) |
503 | Bearer: Bearer realm=R |
Unavailable |
token_expired, token_not_found, client_secret_mismatch, invalid_credentials |
401 | S realm=R; Bearer + bearer token sent: error="invalid_token" (RFC 9068 §4) |
Unauthenticated |
authentication_required, unsupported_credential |
401 | S realm=R — no error code (RFC 6750 §3.1) |
Unauthenticated |
insufficient_scope, access_denied |
403 | S realm=R; Bearer + bearer token sent: error="insufficient_scope" |
PermissionDenied |
no_extractor, _OTHER (no sentinel: a broken contract) |
500 | Bearer: Bearer realm=R |
Internal |
A ConnectRPC error travels over HTTP with the status of the HTTP column, and
the connectrpcsec default mapper puts the same WWW-Authenticate
challenge in its metadata — sent as response headers on unary calls — so
every Connect 401 carries one.
Every 401 carries a challenge with a realm (api by default; RFC 9110
§11.6.1, RFC 7617 §2, RFC 6750 §3), a Basic one also charset="UTF-8"
(RFC 7617 §2.1), and the realm is sent as an RFC 9110 quoted-string — a
realm a quoted-string cannot carry is refused at startup. A Bearer resource
challenges every refusal, 400 and 5xx included: RFC 6750 §3 requires the
challenge whenever the request carried no access token that enables
access. An unauthenticated
principal refused by httpsec.Authorize is asked to authenticate (401), an
authenticated one refused (403) (RFC 9110 §15.5.2, §15.5.4); Authorize
answers with the challenge of the Middleware in front of it. The RFC 6750
error codes are sent only when the request carried a bearer token. The
error_description is a fixed string per code: the response never says why
authentication failed. Clients are expected to branch on the status and the
code, not parse the message. The gRPC messages are one fixed string per
code (unauthenticated, permission denied, …). gRPC clients commonly
retry Unavailable: wrap security.ErrUnavailable only for a transient
dependency failure.
Install the gRPC interceptors for both RPC kinds — grpcsec.ServerOptions
and grpcsec.AuthorizeServerOptions return the unary and stream chains
together: chaining only unary interceptors leaves every streaming RPC (the
health service's Watch, server reflection, your own streams)
unauthenticated.
No secret is ever placed on a span attribute or log line, and the core
spans record the class of an error (security.ErrorClass), never its text,
which can hold user input such as a typed username. See
observability.md for the secrets policy and the full
span catalog.
-
Go toolchain. Every module requires Go 1.27.3 (its
goline). The library cannot be built with a toolchain carrying the standard-library advisories fixed up to go1.27.3: HTTP/2 server crashes and request smuggling innet/http,crypto/tls,encoding/asn1,net/url,encoding/xml, and the distrust of compromised intermediate certificates incrypto/x509(GO-2026-6809), which every TLS client path of the library reaches. Build and ship with the latest Go patch release, and rungovulncheckon your application: which advisories matter depends on the code you call. -
Vulnerability scanning. The
govulncheckworkflow scans every module on every change and weekly, and it must be green on the commit of a release (releasing.md):- govulncheck, outside the workspace (each module's own requirements, tests included), against the Go vulnerability database;
- OSV-Scanner, against the OSV database, which adds the GitHub-reviewed advisories (GHSA) before the Go database imports them, and the records it withdrew. An advisory without symbol data fails whenever a required version is affected; the others go through Go call analysis.
The release workflow scans the released module with both and never publishes over a failing scan. Known and not reachable: GO-2026-5932 (
golang.org/x/crypto/openpgpis unmaintained, no fix) is reported at module level, and by OSV-Scanner as uncalled, for the modules that requiregolang.org/x/crypto; none importsopenpgp. Accepted reachable advisories (ID, reason, owner, review date): none. No scanner covers the security advisories a dependency publishes in its own repository and GitHub never reviews, in practice those without a CVE: releasing.md lists the ones known and the manual check before a release. -
Dependencies. Dependabot proposes version updates for every module after a 7-day cooldown (security updates are not delayed). The development tools (golangci-lint, govulncheck, goveralls, mockery, osv-scanner) are pinned in modules of their own under
tools/and never reach a library's module graph. They run in CI only, checksum-verified by theirgo.sum, with a read-only token and no secret; osv-scanner alone brings about 200 third-party modules into the jobs that build the tools, and the release workflow builds them without a cache. -
CI and releases.
- The workflows run with a read-only token by default (only the release
publish job gets
contents: write), every action is pinned to a full commit SHA, and checkouts keep no credentials. - A release is verified with a read-only token (
scripts/release-check.sh, build, test, lint, govulncheck, OSV-Scanner) before a separate job publishes the GitHub release, without checkout or third-party code. - Module tags are not signed: verify the modules through the Go checksum
database (
sum.golang.org), asgo getdoes by default. Never setGOSUMDB=off, and never let aGOPRIVATEorGONOSUMDBpattern matchgithub.com/hyperscale-stack/*(GOINSECUREandGONOPROXYdo not affect checksum verification).
- The workflows run with a read-only token by default (only the release
publish job gets
- Build with Go 1.27.3 or later, the latest patch release, and run
govulncheckon your application. - Pick a password hasher and review its cost against current hardware;
raise its limits (
WithArgon2idLimits,WithBCryptMaxCost) before importing hashes costlier than the defaults. - HTTP Basic: build each
basic.Authenticatoronce, configure its hasher with the parameters of the stored hashes (and implementbasic.PasswordRehasher), bound the request time (http.TimeoutHandler), and limit failed attempts per account and per client (basic.WithAttemptLimiter). - Behind a reverse proxy, store the client address it forwards
(
security.WithClientAddr, from the hop your proxy appended) before the transport adapters: attempt limits key on it. - Rotate JWT signing keys; expose them through a JWKS endpoint
(
oidc/providerserves one). - Keep the JWT allowlist asymmetric unless you truly need HMAC.
- Build JWT resource servers with
jwtsec.BearerVerifierover aNewVerifier(keys, WithIssuer(…), WithAudience(…), WithType(TypeAccessToken)); fetch the authorization server's keys withNewRemoteJWKSand its default outbound policy, exempting only the subnet of an internal JWKS host (WithOutboundPolicy). - Cap request headers at the transport (
http.Server.MaxHeaderBytes,grpc.MaxHeaderListSize, about 64 KiB): bearer tokens are bounded byjwtsec.DefaultMaxTokenSize(16 KiB) by default. - Use
Profile20BCPor stricter; do not enable theimplicit/passwordlegacy grants without a documented reason. - Give every confidential client that authenticates with a secret a
high-entropy one. Register a client that cannot keep a secret as
public (
none+ PKCE). Declare every client'sGrantTypeValuesandScopeValues— an empty list allows nothing — and watchOnErrorforErrNoGrantTypesDeclared/ErrNoScopesDeclaredafter an upgrade. - When third parties register clients, set
AuthorizeConfig.TrustedRedirect, so that errors before authentication never redirect to a client you do not trust — without it, the server deviates from the RFC 9700 §4.11.2 MUST. - Validate every redirect URI at registration with
oauth2.ValidateRedirectURIand the server's profile; register web clients with https and native apps with a reverse domain name scheme (com.example.app:/cb) or a loopback IP literal (http://127.0.0.1/cb) — neverlocalhost. - Use an https issuer;
AllowInsecureLoopbackIssueris for local development only. SetServerConfig.AdvertiseJWKSURIonly when the issuer's JWK Set is served at{issuer}/.well-known/jwks.json. - Protect the request carrying the consent decision against CSRF (a
session-bound token, or
net/http.CrossOriginProtection); replace the anti-framing headers of/authorizeonly for origins you trust. - Register the resource servers calling
/introspectas confidential clients, have them call it at the issuer of the tokens they accept, and setServerConfig.IntrospectionPolicywhen several resource servers or tenants share the authorization server. - Rate-limit
/token,/revokeand/introspectperclient_idand per source address, and alert on theinvalid_clientfailuresOnErrorreports. - Generate client secrets with at least 128 bits of entropy and keep
only a hash of them, behind a
SecretMatcher(DefaultClientholds them in cleartext). - Give
ServerConfig.Storageand every grant the same store, and wireServerConfig.OnErrorandgrant.Config.OnErrorto the same sink: failed family revocations are reported there. - With
store/sql, scheduleStore.PurgeExpiredwith the current time — every few minutes — after adding theexpires_atindexes the package documentation lists; withstore/redis, run Redis 6.0+ standalone or behind Sentinel withmaxmemory-policy noeviction, and read from the primary only. A revoked family whose state is lost before it expires can be revived. - SQL stores (oauth2, OpenID Connect, SAML) whose schema your
migration tool applies, not
Migrate(which runs it): callCheckDeploymentat boot and treat its error as fatal — on MySQL, InnoDB tables, binary oauth2 key columns and a strictsql_mode. -
oauth2/store/redis: callCheckDeploymentat boot and treat its error as fatal — version 6.0+,noeviction, no cluster mode; the user needs+infofor it. - Keep JWT access tokens short-lived: a store revocation does not reach resource servers that verify them offline.
- Bound refresh-token families with
grant.Config.RefreshFamilyTTL— the longest session you accept, 30 days for instance — so a stolen refresh token kept alive by rotation dies with its family. - Make the
IssuerResolverreturn the same issuer for every endpoint of a realm (/authorize,/token,/revoke,/introspect), and authenticate users withinAuthorizeRequest.Issuerin theConsentFunc. With several issuers and oneClientStore, register every client at its issuers (IssuerBoundClient,DefaultClient.IssuerValues) or scope the store by issuer. - With
store/sql, apply theALTER TABLEstatements of each release (MIGRATION.md) before deploying it; keep PostgreSQL databases inUTF8and MySQL tables inutf8mb4with a strictsql_mode— a non-strict MySQL truncates an over-long value instead of refusing it. - With
oidc/provider: an https issuer theIssuerResolvernever takes from an unvalidatedHost; aKeySourcebacked by your key store, rotated throughKeyRing.Published(publish, sign, retire once the ID Tokens it signed have expired); access-token audiences that are never relying parties' client IDs, and aClientStoreanswering unknown IDs with(nil, nil)orinvalid_client;RefreshTokenScope = offline_access; sessions scoped to their realm (and tenant), and clients to their issuers (IssuerBoundClient); CSRF tokens and anti-framing headers on the login and consent pages;ErrUnknownSubjectfrom theClaimsSourcefor disabled end-users, whose token families you also revoke; every application claim relying parties decide on declared inConfig.ClaimsSupported, and the claim names end-users choose kept apart from them. - Serve over HTTPS so
Securecookies and bearer tokens are protected. - Generate session keys of 32 bytes or more from a cryptographic source and keep them in a secret store; keep the previous key after the new one while you rotate, so rotation does not sign everyone out; rotate the active key before it seals 2^32 cookies.
- Sessions: keep the default
__Host-sessioncookie name; callVerifyCSRFon every state-changing request (sign-in form included) behindnet/http'sCrossOriginProtection. - SAML: import IdP metadata through a trusted channel, or fetch it from
the IdP's https metadata URL with a
MetadataSourcepinned to its entity ID; never take a metadata URL from a request. Widen the outbound policy only for an on-premises IdP (AllowedPrefixes,CheckURL,RootCAs). EnableInsecureAllowSHA1only for an IdP that cannot do better, with a plan to remove it. - SAML: share the transaction store and the replay cache between
instances (
saml/sp/store/sqlorsaml/sp/store/redis); with the SQL store, schedulePurgeExpired, and callCheckDeploymentat boot when your migration tool applies its schema. Give the SP least-privilege access to them: a Redis ACL limited to its key prefix, a database user limited to its two tables. - SAML: load
samlsp.Config.TransactionKeysfrom your secret store — the same keys on every instance, distinct from the OpenID Connect client's and the session keys — and rotate them by adding the new key last, then moving it first, then removing the old one afterTransactionTTL; replace a leaked key at once. - SAML: exempt the ACS routes from CSRF protection, start logins only
from a same-origin gesture (a POST with a CSRF token, or
Sec-Fetch-Sitesame-originornone), rate-limit the login entry point and the ACS per client, serve the login on the ACS host, and key accounts on the connection and the persistent NameID — never on an email. - SAML: let the ACS see the host the browser used: forward the
original
Hostat the proxy, or passCompleteLogina copy of the request whoseHostcomes from your trusted proxy's header. - SAML: set
Connection.ResponseOriginsto the IdP page's origin when that page lets browsers send it (a Keycloak realm needs its Referrer-Policy header changed first); otherwise keepTransactionTTLshort and never log full IdP redirect URLs. - Install an OpenTelemetry
TracerProviderto collect the spans. - Give every route the attributes its voters check, and a voter for each
attribute (an unsupported one denies); require a logged-in caller
with
security.IsAuthenticated, and store roles asROLE_<name>and scopes as onescope:<name>authority each. - Set the challenge scheme of each HTTP / ConnectRPC adapter to the scheme its extractors accept, with a printable-ASCII realm.
- On gRPC, install the authentication and authorization interceptors
for both RPC kinds (
grpcsec.ServerOptions,grpcsec.AuthorizeServerOptions). - In your authenticators, wrap
security.ErrUnavailablefor a dependency that is temporarily down, a credential sentinel for a refusal — never a plain error. - Keep the zero
outbound.Policyin production; exempt only the exact subnets of on-premises identity providers inAllowedPrefixes, and restrict hosts and ports inCheckURLwhen tenants configure URLs. - Give every OpenID Connect connection its own redirect URI
(
/oidc/{connection}/callback), registered exactly at its provider. - Use a shared
TransactionStore(oidc/client/store/sqlorstore/redis) when several instances serve callbacks, and schedulePurgeExpiredwith the SQL store. - Load
oidcclient.Config.TransactionKeysfrom your secret store — the same keys on every instance — and rotate them by adding the new key last, then moving it first, then removing the old one afterTransactionTTL. - Link accounts on (connection, issuer, subject), never on email; create a fresh session after each login.
- Start logins with a same-origin POST and rate limit them; exempt only
the
form_postcallback routes from CSRF middleware. - Rotate an OpenID Provider's keys on its relying parties' timetable:
publish the next key at least five minutes before it signs (more for
relying parties caching longer); after a leak, withdraw the key and
ask relying parties to rebuild their clients (
oidc/clientkeeps a withdrawn key for up to five minutes, up to 24 hours while its JWK Set fetches fail). - Prefer providers that send the RFC 9207
issparameter; never register a connection's redirect URI at another provider. - Serialize the refreshes of one grant across instances: providers that rotate refresh tokens with reuse detection revoke the grant when a rotated token is presented twice.