Design judgments
Every spec the OP touches is a layered set of MUSTs, SHOULDs, and "the authorization server may". Several places need an explicit reading because the literature disagrees with itself, or because a literal reading collides with another spec. This page records those calls, so you can tell whether a behaviour is an accident, a spec compromise, or a deliberate security posture.
Terms used on this page
- OP (OpenID Provider): the authorization server this library implements.
- RP (Relying Party): the client application that signs users in through the OP.
- RS (resource server): the API that accepts access tokens.
- Grant: the OP's record of one subject's consent to one client. Several sessions and tokens can sit under one grant; see Consent.
- Chain: a refresh token and every successor produced by rotation; see Refresh tokens.
- Tombstone: a small revocation record keyed by grant (or by
jti), which lets a JWT be rejected without keeping a row per token.
Decision map
Start with the group for the surface you are changing. Each line is the rule in one sentence; the number links to the full entry.
Protocol profiles and discovery
client_assertion accepts both OIDC token-endpoint and FAPI issuer audiences, except under the two FAPI 2.0 profiles.#25DPoP nonce challenges happen before assertion jti consumption.#26CIBA is poll mode only; slow-down strikes are bounded.#40DPoP proof failures use the OAuth error envelope; only the nonce challenge keeps its own code.Tokens, grants, and revocation
offline_access is a UX / TTL signal by default; strict mode makes it a gate.#15DPoP refresh-token chains bind for public clients, not confidential clients.#16Confidential-only introspection delegates across clients only through the resource audience.#17/end_session retires everything issued under the subject's grants, class by class.#18JWT access tokens are the default; opaque is opt-in and can vary by resource.#19JWT revocation uses grant tombstones by default; FAPI rejects no-revocation.#28Custom grants ask for refresh tokens; the OP owns value and lineage.#29devicecodekit.Revoke owns the cascade, grant record included.#31Grant Management stays explicit while the draft surface is unstable.#32Codes, refresh chains and access tokens carry the state of the authorization that issued them.#34ID-JAGs follow the OP's single signing algorithm; RS256 is available, but only as the whole OP's choice.#35Assertions are single-use by jti, even where the draft allows resubmission.Registration, issuer, and outbound fetch
sector_identifier_uri uses bounded fetch, 24 h success cache, and change detection.#23Custom transports can change trust, not bypass the dial-time SSRF gate.#24Open DCR omitted scope means no registered scopes unless configured.#36client_id URLs are fetched inside the SSRF gate with fixed, non-configurable limits.#37Consent shows the redirect host and warns on loopback for URL-identified clients.#38A changed metadata document does not revoke outstanding refresh tokens.#39Metadata document fetch volume is bounded by the shared gate; Allow is the throttle.Browser, session, and UI boundaries
JOSE and request objects
request_uri is looked up at entry and consumed when a code is emitted.#6JAR request= objects that carry a jti are deduplicated by an OP-side cache.#11none and HS* are excluded; OP-issued artifacts use one algorithm per OP: ES256 by default, RS256 on request.#14PKCE is S256 only across all profiles.#27JWE algorithms are allow-listed and JOSE nesting is capped.1. When a PAR request_uri is consumed
Spec RFC 9126 §2.2 separates client use from AS handling:
RFC 9126 §2.2 (summary) A client MUST use each
request_urionly once. An AS SHOULD treat it as one-time, MAY allow duplicates caused by a user-agent reload or refresh, and MUST reject an expired value.
Conflict A literal "consume on first sight" interpretation breaks multi-step interaction: consent → MFA → locale change can re-redirect to the same /auth?request_uri=… before the original interaction finishes.
The OFCS FAPI 2.0 Security Profile Final plan tests both sides: …-par-ensure-reused-request-uri-prior-to-auth-completion-succeeds requires the OP to accept a re-visit before authentication completes, and …-par-attempt-reuse-request_uri requires it to reject the re-visit afterwards.
Decision
request_uri is looked up at /auth entry (PARs.Find) and consumed at authorization-code issuance (PARs.Consume).
Re-visits during interaction succeed; re-visits after the code is emitted are rejected with invalid_request_uri.
The matching OFCS module fapi2-…-par-attempt-reuse-request_uri exercises the post-emission rejection path; see OFCS conformance.
2. Refresh rotation grace window
Tokens, grants, and revocation
Spec RFC 9700 §4.14.2 requires an AS to detect refresh-token replay for public clients with sender-constrained refresh tokens or rotation. FAPI 2.0 Security Profile Final §5.3.2.1(9) limits refresh-token rotation to extraordinary circumstances; when rotation is used, it allows a time-limited retry with the previous token after the new token is lost.
RFC 9700 §4.14.2 (summary) Rotation issues a new refresh token, invalidates the previous one while retaining the relationship, and revokes the active token when reuse reveals a compromise.
Conflict A strict "single-use, no grace" reading kills any client where the token rotation network round-trip is interrupted (mobile backgrounding, dropped TCP, retried HTTP/2 stream).
The OFCS FAPI 2.0 Final plans run against the FAPI profiles, which do not rotate refresh tokens, so their …-refresh-token module does not exercise a grace window; the window matters to the profiles that rotate.
Decision
Rotated refresh tokens stay accepted for the library's configurable GraceTTL (default 60 s), as long as the chain has not been revoked for reuse detection.
- Within the grace window, presenting the old refresh token returns the saved successor without rotating again. A sender-constrained retry may receive a newly bound access token.
- Reuse after the grace window — or any reuse on a chain that was already revoked — invalidates the entire chain (RFC 9700 §4.14.2) and retires the access tokens of its grant.
The 60-second default is an implementation choice for a bounded lost-response retry path, not an RFC requirement. op.WithRefreshGracePeriod rejects negative values and treats an explicit zero as no grace; otherwise a deployment may choose any non-negative window.
A rotation touches only its own chain. The successor keeps the scope of the token it replaces, even when the refresh request narrowed scope for that one access token. The rotation retires only the opaque access tokens minted from the same chain, so the user's other sessions on the same client keep theirs. store.RefreshToken.Revoked distinguishes "consumed by rotation" from "retired by chain compromise", and store.RefreshToken.ChainID names the chain.
The two FAPI 2.0 profiles do not rotate refresh tokens, as FAPI 2.0 Security Profile Final §5.3.2.1(9) requires, so they have no window: a refresh response carries no new refresh token, the presented one stays redeemable until it expires or is revoked, and op.New rejects the option under them. The rest of this judgment applies only to the profiles that rotate.
Implemented in internal/grants/refresh.Exchanger.tryGrace.
3. offline_access: signal, not gate
Tokens, grants, and revocation
Spec OIDC Core 1.0 §11 simultaneously asserts:
OIDC Core 1.0 §11 (a) the use of Refresh Tokens is not exclusive to the Offline Access use case.
OIDC Core 1.0 §11 (b) when
offline_accessis requested, the Authorization Server MUST ensure the prompt parameter containsconsent.
Conflict Reading (a) suggests refresh tokens are issued whenever the client is granted the refresh_token grant; the scope only governs consent UX. Reading (b) suggests offline_access is the canonical scope for refresh issuance and the consent gate is secondary. Other OPs split — some treat offline_access as mandatory, others as a UX-only marker.
Decision
By default, offline_access is a UX and TTL signal, not the refresh-token issuance gate.
Refresh tokens are issued when the granted scope contains openid and the client's GrantTypes includes refresh_token. If the grant also carries offline_access, op.WithRefreshTokenOfflineTTL may place that chain in the offline lifetime bucket, and the consent/audit surface can reflect the offline reach the user authorised.
Embedders that want the strict §11 reading pass op.WithStrictOfflineAccess(). In that mode:
- refresh-token issuance additionally requires
offline_access; grant_type=refresh_tokenrejects a token whose originating grant did not carryoffline_accesswithinvalid_grant.
The option is incompatible with op.WithOpenIDScopeOptional(), because §11 has no coherent meaning outside OIDC requests.
Implemented in op/options_features.go, internal/tokenendpoint/strict_offline_test.go, and the refresh issuance path.
4. Loopback redirect ports
Registration, issuer, and outbound fetch
Spec RFC 6749 §3.1.2.3 + OAuth 2.1 mandate byte-exact redirect_uri match. RFC 8252 §7.3 says native apps on loopback "MUST be allowed any port at request time".
Conflict The two readings disagree by construction. A CLI tool cannot pre-register every ephemeral OS-assigned port; a strict exact-match policy breaks the canonical native-app flow.
Decision
Default is exact-match (the strict OAuth 2.1 reading). The RFC 8252 §7.3 relaxation is opt-in per client, via a loopback URI in the client's registered redirect_uris.
The OP then ignores a port mismatch only when all four conditions hold:
- the scheme is
http; - the registered hostname is one of the loopback shapes:
127.0.0.1,::1, or the textuallocalhost; - the requested host matches the registered host;
- path, query, and fragment match exactly.
The textual localhost is admitted to keep the authorize-side rule aligned with OIDC Registration's native-client loopback carve-out. Without it, a native app that registered http://localhost/cb would pass registration but fail at /auth once the OS handed it an ephemeral port.
The localhost admission still depends on the embedder having opted in at registration: op.WithAllowLocalhostRedirectURIs() for web clients, or application_type=native for native clients. DNS-rebinding-sensitive deployments keep the strict literal-IP-only posture by leaving both opt-ins off.
HTTPS loopback is not relaxed (no ACME on 127.0.0.1).
5. Iframe-based logout specs
Browser, session, and UI boundaries
Spec Session Management 1.0 and Front-Channel Logout 1.0 are both published OpenID Connect specs, separate from Core.
Conflict Both rely on a third-party iframe being able to read its own cookie from an embedded context. Modern browser defaults (Safari ITP since 2017, Firefox ETP since 2019, Chrome SameSite=Lax default since 2020, third-party-cookie phase-out across 2024–2025) have removed that capability for mainstream browsers.
Decision
Not implemented.
The discovery document does not advertise frontchannel_logout_supported or check_session_iframe. The library ships RP-Initiated Logout 1.0 + Back-Channel Logout 1.0 (server-to-server POST of a signed logout_token) as the modern substitutes.
Embedders that need iframe-based session signalling should pick a different library — this is a load-bearing scope decision, not a backlog item.
6. JAR request-object replay
Spec RFC 9101 §10.8 RECOMMENDS rejecting replayed request objects within their exp window. It does not mandate a registry.
Conflict A naive "verify and accept" follows the minimum JAR rules but allows an intercepted request object to be replayed against a fresh /auth until exp. FAPI 2.0 Message Signing requires a signed request object at PAR, but does not make jti replay detection mandatory. The cache below is a library defense-in-depth choice.
Decision
A request object that carries a jti is deduplicated.
The OP marks jar:<use>:<clientID>:<jti> in ConsumedJTIStore for the verifier's max-age / max-lifetime window plus clock skew. The retention is clamped to that OP-side ceiling, so a client-supplied far-future exp cannot pin a marker indefinitely. The store is the one DPoP proofs and client assertions already use, so a deployment that shares it across instances gets all three defences.
Without a jti:
- at
/authand/par, the request object is accepted: RFC 9101 §6.1 marks the claim optional, and the OFCS FAPI message-signing plans send request objects without one; - at
/bc-authorize, FAPI-CIBA promotesjtito required, and a missing value is rejected (ErrJTIMissing).
A jti longer than 256 bytes is a malformed request object.
7. What a profile turns on
Protocol profiles and discovery
Spec FAPI profiles impose a mixture of requirements: some are routable features such as PAR, JAR, and JARM; others require deployment-owned collaborators. RFC 9700 §2.1.1 requires PKCE for public clients and recommends it for confidential clients. This library applies the stricter all-client rule by default, through profile.OAuth21, and under its FAPI profiles, while OIDC Core compatibility remains available for deployments that need it by declaring profile.OIDCCore.
Conflict Open-coded switch profile blocks in handlers silently diverge. Auto-enabling every requirement is also wrong: a missing feature can be selected safely, but mounting a grant without its store and resolver collaborators creates a half-wired endpoint.
Decision
Profiles accumulate their policy, but features and grants resolve differently: WithProfile auto-enables required features, and never auto-enables required grants.
Features:
- FAPI 2.0 Baseline selects PAR and JAR; Message Signing also selects JARM.
- A FAPI profile selects DPoP unless the embedder explicitly chose mTLS.
profile.OAuth21has no routable feature.
PKCE: the library's profile.OAuth21, profile.FAPI2Baseline, and profile.FAPI2MessageSigning policies make PKCE mandatory for every authorization-code request, including confidential clients, and profile.OAuth21 is what an OP without WithProfile runs. Opting out is explicit: profile.OIDCCore keeps PKCE optional for confidential clients and cannot be combined with any other profile, so the weaker posture never arises as a side effect of an omitted option.
Grants: profile.FAPICIBA without the CIBA grant fails op.New and names WithCIBAGrant(CIBAGrant{HintResolver: ...}) as the wiring path. This preserves a useful construction error instead of mounting /bc-authorize without its collaborators.
The resolved profiles, features, grants, and policy are emitted once as the startup.profile audit event, so operators can verify the actual posture before the first request.
Implemented in op/profile/constraints.go; WithProfile and construction validation apply the constraint functions centrally.
8. ACR on the wire vs internal AAL
Protocol profiles and discovery
Spec OIDC Core 1.0 emits acr claims; RFC 6711 / 8485 govern the identifier registry; RFC 9470 standardises step-up via acr_values + WWW-Authenticate: error="insufficient_user_authentication".
Conflict Internal AAL ("which factors did the user complete?") is not the same vocabulary as the OIDC acr echoed on the wire. Mapping them naively (e.g. acr=urn:authn:aal=2 literal) ties the wire vocabulary to internal taxonomy, which embedders cannot evolve without breaking RPs.
Decision
A two-layer model: the internal AAL ladder and the wire-side acr mapping are kept apart.
RuleACR implements RFC 9470 step-up: an RP that asks for a higher acr_values than the session currently provides receives the insufficient_user_authentication challenge with the next step.
Implemented in op/aal.go (AAL ladder), op/acr.go (acr mapping), and op/rule.go (RuleACR).
9. Issuer canonicalization
Registration, issuer, and outbound fetch
Spec RFC 9207 says the OP MUST emit iss on authorization responses, and clients compare that value with the expected issuer using simple string comparison. OIDC Discovery 1.0 §3 and RFC 8414 §2 define the issuer as an HTTPS URL without query or fragment components and require the metadata issuer to match the issuer used for the configuration request. Discovery §4.1 (and RFC 8414 §3.1) removes a terminating / from a path issuer before constructing the well-known request. FAPI 2.0 Security Profile Final §5.3.2.1(1) requires the AS to distribute that discovery metadata.
Conflict URI syntax permits representations that differ by scheme or host case, default-port spelling, or a trailing slash, and Discovery explicitly removes a terminating slash when constructing the well-known request. These specifications do not require this library to reject every such representation, but RFC 9207 and the metadata specifications still require one exact issuer value at comparison boundaries. If an OP and an RP canonicalize that value differently, issuer validation fails.
Decision
op.WithIssuer deliberately applies a strict canonicalization policy, and op.New returns a build-time error rather than booting on a non-canonical issuer.
It rejects a URI carrying any of these:
- a trailing slash
- a mixed-case scheme
- a mixed-case host
- a default port (
:443for https,:80for http) - a fragment
- a query
- a non-canonical path (
..,., or duplicate slashes detected viapath.Clean)
Query and fragment rejection follows the issuer requirements; the case, port, trailing-slash, and path rules are additional library policy.
The same accepted form is reused for iss in every emitted artifact, in the discovery document's own issuer field, and in the authorization-response iss parameter — so simple string comparison under RFC 9207 mix-up defence has one value end-to-end.
10. Where sessions live
Browser, session, and UI boundaries
Spec None. This is an architecture decision.
Conflict Putting sessions in the same SQL cluster as auth codes / refresh tokens / clients (the transactional store) couples session mutation latency to the transactional path. Putting them on Redis loses durability — but session loss is recoverable (the user logs in again), whereas authorization-code loss is not (the RP loop breaks).
Decision
Sessions are routed through a separate substore (store.SessionStore) that the embedder may serve from a volatile store such as Redis.
/end_session takes the session snapshot before deleting it, and the BCL coordinator resolves its audience from grant-derived client IDs via GrantStore.ListClientIDsBySubject. If eviction happens before that snapshot, no fan-out starts and no BCL no-sessions event is emitted.
audit.BCLNoSessionsForSubject instead means that a session-bearing logout notice reached the coordinator but resolved to zero eligible grant-derived RP targets.
op.SessionDurabilityPosture records the embedder's declared durability, so operators can interpret logout-trigger and snapshot-retention risk without treating the event as a direct eviction signal.
11. JOSE allow-list and one signing algorithm
Spec RFC 8725 ("JWT BCP") §3.1 requires libraries to let the caller specify an algorithm allow-list and to reject algorithms outside it. Section 3.2 makes algorithm selection an application decision, warns about HMAC/public-key confusion, and does not categorically prohibit none when another end-to-end protection exists; libraries SHOULD consume or generate it only when explicitly requested.
Conflict The underlying JOSE library accepts the full registry by default. Adding a runtime check is fragile — a future code path that imports the JOSE library directly bypasses it.
Decision
none and HS* are not in the algorithm type at all, and every artifact the OP signs uses one algorithm per OP: ES256 by default, RS256 on request.
internal/jose.Algorithm is a closed enum: RS256, PS256, ES256, EdDSA. depguard (lint) forbids importing the underlying JOSE package outside a short list of internal/ packages that already hold raw handles (jose, keys, dpop, jar, jarm, mtls, clientauth, and a few more), so a new code path has to be added to that list on purpose.
The verification allow-list is deliberately wider than the issuing policy. Client assertions and request objects may use the accepted asymmetric algorithms. Every artifact the OP signs — ID Tokens, JWT access tokens, signed UserInfo, JARM responses, logout tokens, introspection JWTs, ID-JAGs and Security Event Tokens — uses the one OP algorithm.
- The default is ES256 with a P-256 key.
op.WithSigningAlgorithm(op.AlgRS256)selects RS256 with an RSA key of 2048 bits or more.- These are the only two values the option accepts, and a mismatch between the option and the active key makes
op.Newfail.
The choice is made once for the whole OP. There is no per-client or negotiated algorithm: *_signed_response_alg client metadata must equal the OP's algorithm (or none where the surface allows it), and dynamic registration fills id_token_signed_response_alg with the OP's algorithm. RS256 is refused together with FAPI2Baseline, FAPI2MessageSigning and FAPICIBA, because FAPI 2.0 Security Profile §5.4.1 permits only PS256, ES256 and EdDSA. An RP that requires PS256 for OP-issued tokens is unsupported.
The OP's own verifiers (access tokens, id_token_hint, token-exchange subject tokens) require the JWS header alg to equal the algorithm of the key that matched, in line with RFC 8725 §3.1. Keeping issuance to one algorithm removes algorithm negotiation and its downgrade paths without making the OP reject standards-compliant client assertions.
To move an existing OP between the two, see Switching the signing algorithm.
Implemented in internal/jose (Algorithm), with the import fence enforced by depguard.
12. Discovery narrowed by profile
Protocol profiles and discovery
Spec OIDC Discovery 1.0 + RFC 8414 enumerate the metadata fields. FAPI 2.0 Security Profile Final §5.3.2.1(6) restricts AS client authentication to mTLS or private_key_jwt, which narrows token_endpoint_auth_methods_supported. RFC 9101 §10.1 demands request_object_signing_alg_values_supported when JAR is on.
Conflict The _supported lists are produced by feature activation, then potentially narrowed by the active profile, then copied into the introspection / revocation auth-method lists. Doing this in handlers re-introduces drift; doing it at construction time forces a single point that must agree.
Decision
Discovery is built once at op.New time.
Profiles narrow the auth-method list via ProfileAllowedAuthMethods, and the introspection / revocation endpoints copy the post-narrowed list.
A discovery golden test in the repository asserts the document shape per profile, so silent drift is caught at PR time.
Implemented in internal/discovery/build.go.
13. client_assertion audience
Protocol profiles and discovery
Spec RFC 7523 §3 says the JWT bearer assertion's aud MUST be "a value identifying the authorization server" — without pinning which identifier. OIDC Core 1.0 §9 (private_key_jwt) reads that as the OP's token endpoint URL. FAPI 2.0 Security Profile Final §5.3.2.1(8) narrows the authorization-server requirement: the AS must accept only the issuer identifier, and that value must be a JSON string. FAPI clients have the matching obligation in §5.3.3.1(5). RFC 7519 generally permits a string or an array for aud, but FAPI narrows that shape.
Conflict The library's compatibility verifier accepts OIDC Core's token-endpoint form, FAPI's issuer form, and an array containing an accepted value. That is useful when one OP serves both dialects, but accepting the token endpoint URL or an aud array violates §5.3.2.1(8). A Final FAPI profile needs a stricter validation policy; the broad RFC 7523 interpretation does not override the profile requirement.
Decision
Under the two FAPI 2.0 profiles the verifier accepts only the issuer identifier as a JSON string; every other profile keeps the primary Audience plus AuxAudiences compatibility behavior.
- Compatibility profiles:
op.NewpopulatesAuxAudienceswith the issuer for every OP, and the assertion path consumesjtionce across the merged path, so neither accepted dialect can bypass replay defence. profile.FAPI2Baselineandprofile.FAPI2MessageSigning: the same verifier rejects the token-endpoint audience and the array form withinvalid_client, as §5.3.2.1(8) requires. The profile is a switch on one verifier, not a second one.profile.FAPICIBAfollows CIBA Core §7.1 and keeps the wider set.
Implemented in internal/clientauth/assertion.go.
14. PKCE: S256 only
Spec RFC 7636 §4.2 lists two transformations: plain and S256. §4.4.1 says clients SHOULD use S256, with plain retained for environments that cannot compute SHA-256. OAuth 2.1 (draft-ietf-oauth-v2-1 §4.1.1) and FAPI 2.0 Security Profile Final §5.3.2.2(5) forbid plain outright.
Conflict A literal RFC 7636 reading lets the OP accept whichever method the client requests. A strict reading of OAuth 2.1 / FAPI 2.0 requires rejection. A "per-profile gate" architecture would let the same client succeed against an OIDC-Core OP and fail against the same OP under a FAPI 2.0 profile — the kind of silent profile drift the rest of the codebase works hard to avoid.
Decision
The OP rejects plain regardless of profile.
internal/pkce.Method is the singleton constant "S256"; ValidateChallenge returns ErrChallengeMethodUnsupported for any other value, and code_challenge_methods_supported advertises only S256 in discovery.
Reasoning:
plainprovides no PKCE protection (the verifier IS the challenge).- The SHOULD reading from RFC 7636 has aged out under OAuth 2.1.
- A uniform stance keeps client behaviour consistent across profiles.
Implemented in internal/pkce (Method, ValidateChallenge).
15. DPoP refresh binding: public vs confidential
Tokens, grants, and revocation
Spec RFC 9449 §5.0 / §5.4 say the AS MAY bind refresh tokens to the DPoP key supplied at the token endpoint. §5.4 adds that once bound, the binding MUST persist across rotations.
Conflict "MAY bind" is genuinely two-handed:
- Always-bind locks confidential clients to a single DPoP key for the chain's lifetime, which clashes with FAPI 2.0 OFCS plans that explicitly rotate the DPoP key across refresh requests.
- Never-bind leaves public-client refresh tokens (SPAs, native apps) as raw bearer secrets — exactly the threat model RFC 9449 §1 cites as motivating sender constraints.
Decision
Bind for public clients, leave unbound for confidential.
- A client whose
TokenEndpointAuthMethodis"none"(the public-client signal) gets its refresh chain DPoP-bound on first issue, and the binding propagates through every rotation per §5.4. - Confidential clients (
private_key_jwt,client_secret_*) leave the refresh chain unbound, free to rotate DPoP keys per request. The access tokens minted on each refresh are still bound to whatever key was presented, so any holder of those access tokens still needs the matching private key.
Once a chain is bound, the §5.4 persistence rule kicks in and prevents an opportunistic upgrade from locking later key rotation.
The split applies to DPoP only. A refresh chain issued to an mTLS-bound request is bound to the client certificate for every client type, and each refresh must present the same certificate (requireMTLSMatch).
Implemented in internal/tokenendpoint.refreshDPoPJKT.
16. Who may introspect what
Tokens, grants, and revocation
Spec RFC 7662 §2.2:
RFC 7662 §2.2 an introspection response for a token the AS does not consider active MUST carry
"active": false, and MAY contain only theactivemember.
§2.1 additionally lets the AS "differently respond depending on the audience". Cross-client refusal is therefore permitted, but not mandated.
Conflict Three readings circulate:
- Liberal — let any authenticated client introspect any token the AS recognises, returning
"active": trueeven when the calling client is not the issuer. - Strict-distinguishable — refuse cross-client introspection but signal it (e.g. HTTP 403 or
error: not_authorized). - Audience-scoped — allow the resource server named by the token audience to introspect through an explicit allow-list, while refusing unrelated callers with the same
{"active": false}shape used for unknown / expired / revoked tokens.
Reading 2 leaks token-existence information through the response shape: a curious client can probe whether a guessed token belongs to some live grant.
Decision
Only confidential callers may use /introspect, and cross-client access-token introspection is delegated only through the token audience.
- A public client, or a client whose token-endpoint authentication method is
none, receives401 invalid_clientbefore token resolution. - A confidential client may introspect its own access or refresh token.
- Cross-client access-token introspection is allowed only when the calling client is listed in the token audience's protected resource
IntrospectionClients; the audience check is the delegation boundary. - Refresh tokens are never delegated across clients.
- An unauthorized cross-client access-token lookup returns the same
{"active": false}shape as an unknown, expired, or revoked token.
Active opaque responses include iss. An active refresh-token response omits token_type, because RFC 7662 §2.2 defines that member as the access-token type.
Tests cover the confidential gate, audience delegation, and refresh-token refusal.
Implemented in internal/introspectendpoint and the protected-resource configuration.
17. /end_session cascade reach
Tokens, grants, and revocation
Spec OIDC RP-Initiated Logout 1.0 §6:
OIDC RP-Initiated Logout 1.0 §6 the OP MAY also revoke any active sessions, refresh tokens, and access tokens once the user signs out.
Back-Channel Logout 1.0 §2.3 governs RP-side fan-out but is silent on AT revocation reach.
Conflict A literal "MAY" admits postures from "delete the cookie and walk away" to "revoke every grant the subject holds". The blast radius differs:
- Cookie-only: outstanding access tokens remain valid until their
exp. A JWT that never returns to the OP cannot be retracted at all. - Revoke at the OP-served boundaries:
/userinfoand/introspectsee the token as inactive at once. A resource server that validates a JWT offline does not. - Opaque tokens reach every resource server: the token has no offline verification path, so the next introspection reports it inactive.
Decision
Cascade by default: /end_session lists every grant the subject holds and retires what was issued under each.
- JWT access tokens, by the configured revocation strategy (a grant tombstone by default). Under the tombstone strategy the grant is also closed to further issuance.
- Opaque access tokens.
- Refresh tokens.
Each class goes through the substore the configured Store exposes. A class whose substore is absent is skipped, and a store that exposes no Grants leaves the cookie-only posture, where access tokens expire naturally. A failed class is reported in the audit stream and does not block the sign-out.
The table in #19 compares this reach with the other revocation triggers. The cascade-reach gap between JWT and opaque tokens (JWT → OP-served boundaries only, opaque → every RS via introspection) is documented separately on the access-token-format page; the choice of which form to issue is the lever embedders pull when this gap matters.
18. Access-token format
Tokens, grants, and revocation
Spec RFC 9068 standardises the JWT-shaped OAuth 2.0 access token. RFC 6749 itself treats the access token as an opaque bearer string. RFC 7662 introspection is optional. RFC 8707 lets a single client request tokens for multiple resources in one flow.
Conflict Two opposite defaults are defensible:
- Default JWT — every RS validates locally, no
/introspectround-trip, scales horizontally; but/end_sessioncannot retract a JWT that never sees the OP again untilexp. - Default opaque — every RS hits the OP, latency-coupled with the OP path; but immediate revocation reach.
A single hard-coded default forces every embedder onto one trade-off. Per-audience selection (RFC 8707) lets a single OP issue different forms to different RSes inside the same authorization.
Decision
Default is op.AccessTokenFormatJWT; opaque is opt-in, globally or per audience.
JWT matches the typical horizontal-scale RS layout and keeps the wire shape compatible with off-the-shelf JWT verifiers.
op.WithAccessTokenFormat(op.AccessTokenFormatOpaque)flips the global default.op.WithAccessTokenFormatPerAudience(map[string]op.AccessTokenFormat{...})selects per RFC 8707 resource indicator, so high-revocation-reach audiences (admin APIs, payment APIs) can take opaque while general-purpose APIs keep JWT.
The full trade-off (load concentration, header size, cascade reach, storage shape) lives on the access-token-format page; this entry exists so the decision is recorded alongside the other deliberate spec reads.
19. JWT access-token revocation
Tokens, grants, and revocation
Spec RFC 6749 §4.1.2 says the AS "SHOULD revoke (when possible)" on code re-use. RFC 6819 §5.2.1.1 expects a server-side reuse-detection invariant. RFC 7009 §2.2 lets revocation be unsupported for self-contained tokens. FAPI 2.0 Security Profile Final §5.3.4(3) requires resource servers to verify the validity, integrity, expiration, and revocation status of access tokens. That requirement is enforced at the RS boundary; it does not prescribe an OP-side storage strategy.
Conflict The literal "shadow every jti so revocation can flip a row" model satisfies every spec but loads O(issuance_rate × AT_TTL) rows on the transactional store, and the cascade path on a logged-out user with N outstanding ATs becomes N row updates. The opposite extreme — "no revocation, JWT lives until exp" — leaves a resource server without revocation status unless the deployment supplies another check, so it cannot meet the FAPI 2.0 Security Profile Final §5.3.4(3) requirement.
The middle reading is to keep the JWT self-contained but bind it to a server-side grant rather than to a per-token row. One write per revoked grant fans out to every AT under that grant; issuance writes nothing.
Decision
Default is op.RevocationStrategyGrantTombstone: revocation state is kept per grant, and the default issuance path writes zero rows on /token.
Every JWT AT carries a gid private claim (the OP-side GrantID, RFC 7519 §4.3, omitempty). Verification at OP-served boundaries (/userinfo, /introspect) consults a small per-grant tombstone table keyed on gid.
- Cascades (logout, code-replay, refresh compromise) write one tombstone row per revoked grant.
- Single-AT
/revokewrites one deny-list row per revokedjti. - Steady-state row count is
O(revoked grants + revoked JTIs), notO(issued).
The other strategies:
op.WithAccessTokenRevocationStrategy(op.RevocationStrategyJTIRegistry)is for embedders who need a per-AT audit trail; it keeps one shadow row per issuance instore.AccessTokenRegistry.op.RevocationStrategyNoneis a third option for non-FAPI deployments that explicitly accept the RFC 6749 §4.1.2 "SHOULD" wiggle;op.Newrejects it under any FAPI profile.
Both the tombstone and the registry strategies provide server-side revocation state at the OP-served boundaries, but selecting either OP strategy alone does not establish FAPI Final conformance: resource servers still have to satisfy §5.3.4(3).
The opaque AT path (op.WithAccessTokenFormat(op.AccessTokenFormatOpaque)) is unaffected by this enum: opaque verification needs the row, so storage is intrinsically per-token.
Revocation triggers. Every trigger routes through one owner (internal/grants/teardown), which states the blast radius per trigger. A grant is shared by all of one subject's sessions on one client, so most triggers deliberately stop short of the whole grant:
| Trigger | Refresh tokens retired | Access tokens retired | Grant closed to further issuance |
|---|---|---|---|
/end_session, for each grant the subject holds | all of the grant's | all of the grant's, JWT and opaque | yes |
| Authorization-code replay | all of the grant's | all of the grant's | yes |
| Refresh-token replay (reuse after the grace window) | the replayed chain; all of the grant's if the chain root cannot be resolved | all of the grant's | yes |
/revoke of a refresh token | that chain; all of the grant's if the chain root cannot be resolved | all of the grant's | no: other chains and unredeemed codes keep redeeming |
| Refresh rotation | the previous token | the opaque ones minted from the same chain | no |
"Closed" holds under the tombstone strategy: the tombstone also refuses the grant's refresh and code redemption.
The /revoke row writes an access-token-only tombstone (store.GrantTombstone.AccessTokensOnly), and a store that implements the optional store.IssuanceRevocationChecker lets the token endpoint ignore that kind when it decides whether a chain or code may still mint. The chain-wide opaque retirement on rotation needs store.OpaqueAccessTokenChainRevoker. A store that implements neither, or drops ChainID, falls back to grant-wide behaviour, which over-revokes rather than under-revokes. /revoke records token.revoked with surface refresh_chain, or refresh_grant when it fell back to the grant's refresh tokens.
A code or refresh token presented by the wrong client, or one failing the redirect_uri or PKCE check, is not a replay: it consumes nothing and tears down nothing. Only a genuine replay triggers the rows above.
A grant revoked while a code exchange, refresh, token exchange, or device-code or CIBA poll is in flight leaves no live credential. After persisting what it issued, the token endpoint re-checks whether the grant is closed and revokes the credentials it just minted. Grant teardown records that closure under every access-token revocation strategy, not only the tombstone one.
Implemented in op/access_token_revocation.go, internal/tokens (gid claim), op/store/grant_revocation.go, internal/grants/teardown, and the backend adapters.
20. DCR client_secret storage
Registration, issuer, and outbound fetch
Spec RFC 7591 §3.2.1 marks client_secret as OPTIONAL in the registration response. RFC 7592 §2.1 (read) and §2.2 (update) both allow but do not require the OP to re-emit client_secret in the response body.
Conflict A "store the cleartext so we can re-emit it on every GET" reading is the simplest implementation and matches what some historic OPs do, but it places the cleartext in a recoverable form forever and turns the registration record into the most sensitive row in the database.
A "hash on POST and never re-emit" reading matches how every other credential in the OP is stored and removes the recoverable surface entirely, at the cost of asymmetry between the POST response (plaintext available once) and the GET response (plaintext gone forever).
Decision
Hash-only at rest. The plaintext is returned exactly once on POST /register; GET /register/{client_id} never re-emits client_secret.
PUT /register/{client_id} returns it again only when token_endpoint_auth_method changes from none to a confidential method. A confidential-to-confidential update keeps the existing hash, so editing metadata never rotates the secret.
Embedders that need recoverable access to the plaintext keep their own copy at the moment of the original POST response; the OP refuses to be the system of record for the cleartext.
Storage is governed by op/store/client.go (SecretHash-only), so the same posture applies to every store.ClientStore adapter.
Implemented in internal/registrationendpoint/manage.go and internal/registrationendpoint/register.go.
21. RFC 7592 PUT with omitted fields
Registration, issuer, and outbound fetch
Spec RFC 7592 §2.2 reads in two parts:
RFC 7592 §2.2 (sentence 1) the values of the metadata returned in the response MUST replace, not augment, the values previously associated with this client.
RFC 7592 §2.2 (sentence 2) the server MAY ignore any null or empty value in the request just as any other value.
Conflict A strict reading of the first sentence makes a missing grant_types field on PUT mean "delete grant_types", which leaves the client with no grant capability and breaks every subsequent /token request. The MAY clause in the second sentence is the escape hatch every reference OP uses to avoid that footgun, but the ecosystem has not converged on a single replacement policy.
Decision
Omitted defaulted fields reset to the server-side default. Omitted optional fields become empty.
- Defaulted:
grant_types,response_types,token_endpoint_auth_method,application_type,subject_type, andid_token_signed_response_alg. On PUT they fall back to the OP's documented defaults rather than vanishing from the record. - Optional metadata (
client_uri,logo_uri,policy_uri,tos_uri,contacts, …) is genuinely cleared when omitted. - Server-managed fields —
registration_access_token,registration_client_uri,client_secret_expires_at,client_id_issued_at— are rejected with400 invalid_requestif present in the body. Aclient_secretvalue that does not match the authenticated client is rejected with the same status.
The OP does not persist a separate "configured vs defaulted" bit for these fields; the on-the-wire behaviour is the same either way for every defaulted field.
Implemented in internal/registrationendpoint/manage.go (validateManageUpdateRequest) and internal/registrationendpoint/metadata.go (applyMetadataDefaults).
22. sector_identifier_uri fetch and loopback hosts
Registration, issuer, and outbound fetch
Spec OIDC Core 1.0 §8.1 makes the sector_identifier_uri fetch and the containment check on the registered redirect_uris a MUST:
OIDC Core 1.0 §8.1 the values registered in
redirect_urisMUST be included in the elements of the array, or registration MUST fail.
The spec fixes neither a timeout nor a body cap. OIDC Registration §2 lists localhost, 127.0.0.1, and [::1] as valid loopback hosts for application_type=native. RFC 8252 §8.3 marks localhost as NOT RECOMMENDED in favour of the IP literals, citing DNS rebinding.
Conflict "Fetch with the language default" implicitly means "hold a goroutine open until the upstream eventually answers", and the request body is unbounded — both are footguns at registration time.
On localhost, OIDC Registration §2 and RFC 8252 §8.3 disagree: the OIDC text accepts it for native, the OAuth text warns against it. A web client that registers http://localhost/cb is a separate question from a native client that does the same.
Decision
The fetch is bounded to a 5 s timeout, a 64 KiB body cap, HTTPS only, no redirects, and a 24 h success cache.
Failures are not cached. A cache hit still re-runs the redirect-URI subset check against the currently registering client, so the cache never widens what a different client can register under the same sector.
When a later fetch observes that the remote document's canonical hash changed, the resolver returns ErrSectorContentChanged once and evicts the stale entry. The next valid registration for that URI repopulates the cache with the new document. Legitimate RP-side rotations recover without an OP restart, while unexpected sector changes still surface to operators.
Failure or containment mismatch produces 400 invalid_client_metadata. The cause goes to the audit log but never to the response body, so upstream details (host, TLS state, partial bytes) do not leak.
For loopback hosts, the registration layer splits on application_type:
- Web clients (the default) accept
127.0.0.1and[::1]overhttp. The textuallocalhostis rejected unless the embedder explicitly opts in viaop.WithAllowLocalhostRedirectURIs(). - Native clients (
application_type=native) accept all three loopback hosts unconditionally per OIDC Registration §2, plus claimedhttpsredirects and reverse-DNS custom URI schemes (com.example.app:/cb) per RFC 8252 §7.1. Custom schemes that lack a.are rejected because non-reverse-DNS schemes collide across applications.
The authorize-time port wildcard for loopback URIs is governed by a separate per-client opt-in (#4) and composes with this rule without overlap.
Implemented in internal/registrationendpoint/sector_identifier.go and internal/registrationendpoint/metadata.go (validateRedirectURI / validateNativeRedirectURIScheme).
23. Outbound fetch SSRF boundary
Registration, issuer, and outbound fetch
Spec OIDC Dynamic Registration, JAR, private_key_jwt, pairwise subjects, and Back-Channel Logout all let RP-controlled metadata point the OP at outbound URLs. Those specs define what has to be fetched, but they do not define a common SSRF envelope for private networks, loopback, DNS rebinding, or custom TLS roots.
Conflict A deployment often needs a custom transport for conformance runners, internal CAs, or RP networks that are not publicly trusted. Letting a caller replace the HTTP client wholesale would also let them accidentally remove the dial-time SSRF guard. Refusing custom transports keeps the guard simple but makes otherwise valid deployments impossible.
Decision
Outbound trust and outbound reachability are separate knobs. A custom transport alone never widens the network boundary.
op.WithJWKSHTTPTransport lets embedders supply TLS trust and transport settings for RP JWKS fetches. What the SSRF gate still sees depends on the transport:
*http.Transport: the package replaces its dial functions with the gated dialer, so the SSRF deny-list still runs at connect time.- Any other
http.RoundTripper: checked on the request URL of every request and redirect hop only, which does not stop a DNS-rebinding peer. - Through a proxy: the dial-time gate sees the proxy's address, and the target URL is checked once before the hand-off.
Private-network admission remains controlled by the explicit WithAllowPrivateNetwork(op.OutboundJWKS, op.OutboundJAR, ...) policy knob.
The same bounded-fetch posture is used for JAR JWKS, client-auth JWKS, sector_identifier_uri, and Back-Channel Logout destinations: URL-shape validation first, dial-time network checks second, bounded response handling third.
Implemented in op/options_session.go, internal/netsec, internal/securefetch, internal/jar, internal/endpointsupport/clientauth.go, and internal/registrationendpoint/sector_identifier.go.
24. Open DCR without scope
Registration, issuer, and outbound fetch
Spec RFC 7591 makes scope optional in registration metadata. In an IAT-bound registration flow, the issuer of the Initial Access Token is operator code and may attach an explicit AllowedScopes policy. In open registration, there is no such operator-issued per-request envelope.
Conflict Treating omitted scope as "all public scopes" makes a no-body open-registration POST surprisingly powerful. Treating it as "no scopes" is safer, but a client then has to ask for scope explicitly, or the embedder has to configure a default.
Decision
Open registration defaults omitted scope to an empty registered scope set.
A later /auth request that asks for scopes the client did not register is rejected as invalid_scope. Embedders that intentionally want a public default opt in via DynamicRegistration.OpenRegistrationDefaultScopes, which is validated against the OP scope registry at construction time.
The IAT-bound path keeps the broader operator-trusted default: if the IAT has no AllowedScopes, the request can default from the public scope registry.
Implemented in op/registration.go, op/options_validate.go, internal/registrationendpoint/handler.go, internal/registrationendpoint/register.go, and internal/registrationendpoint/metadata_validate.go.
25. DPoP nonce before assertion replay
Protocol profiles and discovery
Spec RFC 9449 lets an AS challenge a token request with use_dpop_nonce; the client then retries with a fresh DPoP proof. RFC 7523 requires replay protection for JWT client assertions through jti / time-window checks.
Conflict If the token endpoint authenticates the client first, the first request consumes the client_assertion jti and then returns use_dpop_nonce. A client that retries the same form body with only the DPoP proof changed then fails as invalid_client / assertion replay, even though the nonce challenge is the expected RFC 9449 recovery path.
Decision
DPoP nonce validation runs before client authentication on token and PAR paths.
A missing or stale DPoP nonce produces the nonce challenge without burning the form-borne client_assertion. Once the DPoP proof is acceptable, the normal private_key_jwt replay cache consumes the assertion exactly once.
Implemented in internal/tokenendpoint/authcode.go, the refresh-token token path, and internal/parendpoint/par.go; pinned by internal/tokenendpoint/refresh_dpop_nonce_pkjwt_test.go.
26. CIBA polling and errors
Protocol profiles and discovery
Spec CIBA Core 1.0 defines poll, ping, and push modes, slow_down, and a general §13 error vocabulary. It leaves the poll-abuse lockout threshold implementation-defined. FAPI-CIBA layers signed request-object requirements on top of CIBA but conformance modules still expect CIBA-shaped wire errors.
Conflict A strict poll ladder without a cap lets a client ignore slow_down until auth_req_id expiry. A hard-coded low cap protects production but can break OFCS modules that intentionally exercise repeated polling.
Separately, surfacing detailed JAR parser errors from /bc-authorize would be helpful for debugging but leaks a JOSE-specific taxonomy through a CIBA endpoint that expects invalid_request.
Decision
The library implements CIBA poll mode only and applies a bounded slow-down strike counter.
- The default cap is 5 violations.
CIBAGrant.MaxPollViolationsraises or lowers it;0means the library default, and255effectively disables lockout because the counter isuint8. Production deployments should keep a finite cap. op.Newalso refuses a negativeCIBAGrant.DefaultRequestTTLorCIBAGrant.PollInterval, and under FAPI-CIBA a default or maximum expiry above 10 minutes.
At /bc-authorize, duplicate single-valued parameters are rejected and request-object failures map to invalid_request per CIBA Core §13. Under FAPI-CIBA, the JAR verifier requires iat, caps request-object lifetime at 60 minutes, and accepts RP JWKS fetched through the same SSRF-bounded fetcher as the rest of the OP.
Implemented in op/options_ciba.go, internal/ciba/polling.go, internal/cibaendpoint, internal/jar, and op/op_builders.go.
27. JWE allow-list and nesting cap
Spec RFC 7516 and RFC 7519 allow a broad set of JWE algorithms and nested JWT shapes. In principle, a JWT can be a JWE wrapping another JWE wrapping another JWT, and so on.
Conflict Accepting every JOSE shape supported by a general-purpose library widens the cryptographic surface beyond what the OP needs. A per-layer plaintext cap protects memory for each decryption, but it does not by itself bound an attacker-controlled nesting chain.
Decision
JWE is closed by policy and nested JOSE traversal is capped.
The OP:
- accepts only its explicit
algandencallow-lists; - rejects unknown
crit; - caps decrypted plaintext at 1 MiB;
- rejects the 11th JOSE layer with
ErrJWENestingTooDeep.
A normal encrypted request object is two layers at most (JWE wrapping JWS), so the 10-layer ceiling leaves room for future protocol shapes without making recursion unbounded.
Implemented in internal/jose/jwe.go, wired through JAR / PAR verification; pinned by internal/jar/verify_jwe_test.go plus scenario coverage for deeply nested encrypted request objects.
28. Custom-grant refresh tokens
Tokens, grants, and revocation
Spec RFC 6749 §6 treats a refresh token as a credential issued by the authorization server. RFC 9700 §4.14.2 requires the AS to detect refresh-token replay for public clients with either sender-constrained refresh tokens or rotation; rotation issues a new token, invalidates the previous token while retaining the relationship, and revokes the active token when reuse reveals a compromise.
Conflict Custom grants are intentionally handler-defined. Letting a handler return an arbitrary refresh-token string is tempting because it mirrors AccessToken, but the OP cannot rotate, bind, or cascade-revoke a credential whose value and lineage it did not create. Rejecting refresh tokens entirely is simpler, but it prevents legitimate custom grants and token-exchange policies from using the OP's existing rotation machinery for long-lived service chains.
Decision
Handlers signal intent; the OP owns the refresh-token value.
CustomGrantResponse.IssueRefreshToken asks the OP to mint and persist the refresh token through RefreshTokenStore. The record shares the issued access token's grant identity and DPoP / mTLS confirmation, so the normal rotation, replay cascade, grant revocation, and introspection semantics apply. A handler never supplies the refresh-token string directly. Token exchange carries the same policy shape through TokenExchangeDecision.IssueRefreshToken.
Issuance is a five-gate decision:
- the provider must serve
refresh_token; - the client must be registered for it;
- the response subject (or the bound access token's subject) must be non-empty;
- the response scope must be non-empty;
- the response may contain at most one audience.
If any gate fails, the access-token response and any otherwise eligible ID Token still succeed, the refresh token is omitted, and custom_grant.refresh_dropped records the drop reason. An empty scope therefore drops only the refresh token.
The handler, not a grant record, vouches for the subject of a generic custom grant's credentials. Provider.RevokeSubject therefore leaves them live until they expire or are revoked individually, and pairwise /userinfo answers invalid_token for them, unless the handler records a grant. Revoking them when an account is removed is the handler's job. Provider.ForgetSubject calls RevokeSubject and also deletes the subject's browser sessions, lockout record and authentication-factor records, so it does not reach these credentials either.
Token exchange differs: its credentials live under the subject_token's grant, so revoking that grant revokes them, and a refresh token obtained through exchange stops rotating once the subject is deleted.
Implemented in internal/tokenendpoint/customgrant.go.
29. Device-code revocation cascade
Tokens, grants, and revocation
Spec RFC 8628 defines the device authorization state machine but does not spell out what should happen to access tokens already issued from a device authorization when the user later removes that device. RFC 7009 gives the AS a revocation endpoint, but device unenrolment is an embedder-owned UX action rather than a standard wire request.
Conflict If revocation only flips the device-code row to denied, future polls stop but already-issued access tokens remain usable until exp. If every embedder must subscribe to an audit event and run RevokeByGrant itself, the security posture depends on remembering out-of-band glue code in each deployment.
Decision
The public helper owns the cascade. devicecodekit.Revoke:
- denies the device-code row;
- recomputes the grant identity the device flow allocated for it — a one-way derivation of the device-code ID, so the device code itself is never republished — and retires what was issued under that grant: JWT access tokens through
Deps.RevocationStrategy, opaque access tokens whenDeps.OpaqueAccessTokensis set, and refresh-token chains whenDeps.RefreshTokensis set; - when
Deps.Grantsis set, deletes the consent record last, after the cascade completed, so that consent no longer satisfies a later authorization request.
Every class is attempted even if an earlier one fails. A partial failure returns a joined error and records cascade_complete=false, and repeating the call finishes the job.
A substore left nil is skipped. JWT-only deployments, and deployments that drive revocation out of band, still get the denied row and the audit event, and the helper does not pretend the skipped cascade happened.
device_code.revoked carries one extra per attempted class (revoked_access_tokens, grant_tombstone_written, revoked_opaque_access_tokens, refresh_token_cascade_complete, grant_record_deleted) plus cascade_complete, so operators can alert on a cascade that fell short.
Implemented in op/devicecodekit.Revoke.
30. When cookie keys are required
Browser, session, and UI boundaries
Spec OIDC Core and OAuth 2.0 define the authorization and token protocols, but they do not mandate that an OP use browser cookies at all. Cookie encryption is an implementation detail of this library's interaction/session binding.
Conflict Two implementation postures are plausible:
- Always require cookie keys — simpler documentation and fewer branches, but a
client_credentials-only OP or other no-browser deployment must configure a secret it will never use. - Require cookie keys only when browser authorization is enabled — more precise and friendlier to machine-to-machine deployments, but the option layer must know which grants need encrypted cookies before any handler is mounted.
Decision
WithCookieKeys is required when the enabled grant set contains authorization_code, which is true for the default grant set.
WithIssuer, WithStore, and WithKeyset are unconditional construction requirements. The authorization-code flow uses encrypted session / CSRF cookies to bind browser interaction state; client_credentials-only deployments that explicitly remove authorization_code can boot without cookie keys.
The rule is centralised in validateCookieKeysRequired, so future grants that need authorize-endpoint cookies can opt into the same check without changing handler code. Individual keys must still be exactly 32 bytes, matching AES-256-GCM.
Implemented in op/options_validate.go; documented in Required options.
31. Grant Management stays opt-in
Tokens, grants, and revocation
Spec OAuth 2.0 Grant Management is still an IETF draft. It defines stable grant_id values plus create, replace, merge, query, and revoke actions, but the wire shape may still change before publication.
Conflict A long-lived consent OP benefits from naming grants and letting clients query or revoke them later. Enabling a draft feature by default, however, would expose a moving wire contract to every deployment and make the discovery document advertise semantics the operator may not be ready to support.
Decision
Grant Management is off by default and explicitly marked experimental.
op.WithGrantManagement(op.GrantManagement{Actions, ActionRequired}) must name the exact action set the OP accepts. The same set is advertised in discovery and enforced at PAR / authorize-time (create / replace / merge) and endpoint-time (query / revoke). Unknown actions, duplicate actions, an empty action set, or an action/grant_id mismatch fail at construction or request validation.
When enabled, token responses carry grant_id and the /grant_management query / revoke endpoint is mounted:
- a confirmed missing or foreign grant returns
404; - a store error that leaves existence uncertain returns
500 server_errorwithout attempting a cascade; - a cascade failure also returns
500, emits the failure audit event, and leaves the grant retryable/queryable; - a final delete racing with another successful delete is the idempotent
204case.
The draft remains isolated behind this option, so a future wire-incompatible draft bump can be handled as an explicit migration rather than an accidental default change.
Implemented in op/grant_management.go, internal/parendpoint, internal/authorizeendpoint, internal/grantmgmtendpoint, and internal/tokenendpoint.
32. Per-authorization state on the credential
Tokens, grants, and revocation
Spec OIDC Core 1.0 defines auth_time, acr and amr as properties of the authentication event behind an ID Token, and scopes a claims request (§5.5) to the authorization request that carried it. RFC 9396 ties authorization_details to what the resource owner granted in that request.
Conflict The library keeps one store.Grant per (subject, client) pair and reuses it across authorizations, because it carries the consent. Reading auth_time, claims or details from that shared record when a credential is used lets a later authorization rewrite what an earlier one reports. A code redeemed after a second login by the same user would show the second login in its ID Token, release the claims the second request asked for, and pick up authorization_details it was never granted.
Decision
The credential carries the state of the authorization that issued it.
store.AuthorizationCode, store.RefreshToken and store.OpaqueAccessToken hold AuthTime, ACR, AMR, AuthorizationDetails and the claims request. A refresh rotation copies them forward, so the ID Token, access token and introspection response of a chain describe its own authorization.
- A Grant Management
replacestill withdraws details from live tokens; nothing a later authorization adds reaches them. - An authorization served silently without a
claimsparameter releases only what its own request asked for, never what an earlier request on the same grant asked for.
JWT access tokens issued from a code or its refresh chain also carry a userinfo_claims private claim: the userinfo member of the claims request, or {} when it requested none. /userinfo releases claims from it; resource servers should ignore it. The name is reserved, so a custom grant cannot set it through its extra claims.
AuthorizationRecorded marks a row that carries the state. A row without the marker, such as one written by a store that does not persist these fields, falls back to the grant, so a bring-your-own store keeps working with the shared-grant behaviour until it persists them.
Implemented in internal/tokenendpoint, internal/tokens and op/store.
33. Step-up on a reused session
Browser, session, and UI boundaries
Spec OIDC Core 1.0 §3.1.2.1 defines prompt and max_age, and RFC 9470 defines step-up through acr_values. Neither says how an OP's own policy rules interact with single sign-on, where the user already holds a session.
Conflict A LoginFlow rule such as RuleClient or RuleScope guards a client or scope behind an extra factor. If rules are evaluated only while the factor chain runs, a session established with the primary factor alone reaches the guarded client or scope through a cached grant that covers the request, or through the first-party consent skip, without ever meeting the rule.
Decision
Rules and the Decider are evaluated before minting from a session, as if the chain had just completed Primary.
When one demands a step, /auth runs the chain from the session's subject instead of minting, and prompt=none answers interaction_required.
A rule's step counts as done when the session's amr already records what that step records:
pwdfor a password;otpfor TOTP, recovery codes and email OTP;hwkfor a passkey (hwkorswkwhen the step does not require user verification).
The three otp factors stand in for each other because the amr does not tell them apart. An op.StepOneOf counts when any of its steps would, and an op.ExternalStep or op.StepCaptcha is never satisfied by the session and is asked again. A relying party that needs a fresh factor sends max_age or prompt=login, or requests an essential acr above the session's level. Risk-driven rules still fire only inside the chain.
A session likewise satisfies acr_values and an essential acr claim when the configured op.ACRPolicy accepts one of the requested values for the session's level. With op.ACRLevels{"urn:example:mfa": op.AAL2}, an AAL2 session is reused and an AAL1 session still re-authenticates. The policy that picks the acr a response reports is the one that decides whether a session may serve it.
After a step-up, the issued tokens keep the session's acr, amr and auth_time rather than reporting only the step-up factors. Risk assessors see the session's methods in AMRSoFar, and step-up factors are audited as mfa.* events rather than login.*.
The evaluation fails closed:
- a panicking
Rule.Whencounts as a match; - a
Deciderthat panics, returns nil, a nil pointer or an unknown shape denies; - a factor whose
interaction.Resultnames a subject other than the one an earlier factor or the account chooser bound ends the chain with a server error, and the same check covers a factor that completes inBeginwithout a prompt.
Implemented in internal/authorizeendpoint, op/rule.go and op/decider.go.
34. ID-JAG signing algorithm
Tokens, grants, and revocation
Spec The Identity Assertion JWT Authorization Grant draft (draft-06) makes RS256 mandatory-to-implement for both the identity provider and the resource authorization server, with ES256 a SHOULD for the resource side.
Conflict This OP signs every artifact it issues with one algorithm (#11). Adding a separate algorithm for one token type would reopen the single-algorithm policy for a draft that is still moving. With the default ES256, a resource authorization server that implements only the mandatory algorithm cannot verify this OP's ID-JAGs.
Decision
An ID-JAG is signed with the OP's issuing algorithm, and there is no separate ID-JAG setting.
- With the default ES256, the identity provider role is reported as partial in the RFC compliance matrix.
- An OP built with
op.WithSigningAlgorithm(op.AlgRS256)signs its ID-JAGs with RS256, the draft's mandatory algorithm, and the same switch moves every other issued token to RS256 as well.
The optional id_jag_signing_alg_values_supported discovery member is not published under either algorithm. Verification of incoming assertions accepts RS256, PS256, ES256 and EdDSA, so the resource authorization server role is not affected.
An embedder whose counterpart requires RS256 for ID-JAGs while other relying parties require ES256 needs external or multi-algorithm signing, which is a separate decision.
Implemented in internal/customgrant/tokenexchange and internal/tokens.
35. Single-use assertions
Tokens, grants, and revocation
Spec RFC 7523 §3 makes jti optional and treats single use as a server choice. The ID-JAG draft allows a client to submit the same grant again within its lifetime to obtain further access tokens.
Conflict Resubmission lets a client renew an access token without going back to the identity provider, but it also turns a leaked assertion into a bearer credential that works for its whole lifetime. A short assertion lifetime narrows that window without closing it.
Decision
jti is mandatory and every assertion is redeemable once, for the plain and the ID-JAG profile alike.
The jti is recorded in the consumed-jti store under its own namespace, and the replay check runs last, so a rejected assertion does not burn its jti. The accepted lifetime is capped at five minutes plus a 60 second leeway, and there is no knob to relax either.
A client that needs a new access token after the first expires obtains a new assertion. In a multi-node deployment the store must be shared; otherwise the replay window is as long as the assertion lifetime.
Implemented in internal/assertiongrant and internal/tokenendpoint.
36. client_id URL fetch limits
Registration, issuer, and outbound fetch
Spec The Client ID Metadata Document draft lets any HTTPS URL act as a client_id, and the authorization server fetches that URL on the strength of a request from an unauthenticated party. The draft names the SSRF risk and suggests limits, but leaves the values to the implementation.
Conflict The fetch is triggered by an attacker-chosen URL, so every limit is an attack-surface setting. Making the limits configurable invites a deployment to loosen them for one awkward client. Fixed limits can refuse a client whose document is unusually large or slow.
Decision
The fetch uses the same dial-time SSRF gate as the other outbound fetches, and its limits are fixed. None of these values is an option.
- URL: only
httpsURLs with a path and no query, fragment, userinfo or dot segments are fetched (255 bytes at most, ASCII host). Anything else is an unknown client and is never fetched. - Response: must be a
200withapplication/jsonorapplication/*+json. Redirects are not followed, the body is capped at 5 KiB, and the request times out after 5 seconds. - Network: private, loopback, link-local and metadata addresses are refused when the connection is made.
- Cache: a document is cached for its
Cache-Controllifetime clamped to between 60 seconds and 24 hours. A failed or invalid fetch is remembered for 5 seconds, so the same URL is not refetched on every request.
op.WithAllowPrivateNetwork(op.OutboundClientIDMetadata) admits private targets and http for relying parties on a private network. It does not affect the document's jwks_uri, which stays under op.OutboundJWKS.
The reason for a failed fetch goes to the audit stream only; the client sees an unknown client. A registered client with the same ID always wins and nothing is fetched.
Implemented in internal/cimd and internal/securefetch.
37. Consent for URL-identified clients
Registration, issuer, and outbound fetch
Spec The MCP authorization specification requires showing the redirect URI host on the authorization screen and recommends warning when it is a loopback address. The draft recommends showing the host of the client_id URL.
Conflict A URL-identified client has no registration step where an operator reviewed its redirect URIs. Anyone can reuse a well-known client_id URL and send the user to a redirect URI the document legitimately lists, such as a loopback address on the attacker's machine. The client_id host alone cannot tell the two apart. Showing a logo is the usual way to build trust, but fetching it from the consent page lets the client's host track who is looking.
Decision
The consent screen shows the host of the client_id URL, the host of the requested redirect_uri, and a warning when that redirect is a loopback address. The logo is never shown.
- A URL-identified client is never covered by the first-party consent skip, and putting such a URL in
op.WithFirstPartyClientsis refused at construction. - PKCE is required for these clients even with
private_key_jwt. - Custom consent drivers receive the same values in
interaction.ClientView(MetadataDocumentHost,RedirectHost,RedirectLoopback) and are expected to display them.
Implemented in internal/authorizeendpoint, op/interaction and internal/authorize.
38. Changed metadata documents
Registration, issuer, and outbound fetch
Spec The draft says an authorization server may need to invalidate existing grants when a client's metadata changes, such as its redirect URIs or keys.
Conflict Revoking on change needs the OP to compare each fresh document with the previous one, keep stale copies, and decide which edits count. Without it, a document whose jwks is replaced lets the new key redeem refresh tokens issued before the change.
Decision
A new document applies from the next fetch, and existing grants and refresh tokens are not revoked.
To exploit this, an attacker would have to take over the host that serves the document and also hold a stolen refresh token for that client. Both are needed, and the refresh token alone is already a bearer secret for a public client.
Redirect URIs and keys are re-read from the latest document on every use, so a removed redirect_uris entry stops working as soon as the cache expires. If the document disappears, the next refresh finds an unknown client and fails with invalid_client.
Revisit this if the draft makes revocation on change mandatory.
39. Metadata fetch volume
Registration, issuer, and outbound fetch
Spec The draft warns that an authorization server can be made to fetch many URLs and suggests rate limiting, without prescribing where.
Conflict A per-origin budget would be a second rate limiter inside a library that deliberately leaves generic HTTP rate limiting to the embedder. Having no bound at all would turn the OP into a request amplifier.
Decision
Fetches are bounded by the shared outbound gate, and there is no per-origin budget; ClientIDMetadataDocuments.Allow is the throttle.
- Fetches share the process-wide outbound load gate with JAR and client JWKS fetches (64 concurrent).
- Concurrent requests for one URL are merged into a single fetch.
- Failures are cached for 5 seconds.
The worst case is therefore 64 concurrent fetches, each capped at 5 seconds and 5 KiB. When the gate is full, the request fails with server_error and the failure is not cached.
ClientIDMetadataDocuments.Allow receives the client_id URL on every resolution, cache hits included, before any fetch. An embedder narrows exposure there with an allow-list or its own limiter, and a tightened list takes effect immediately.
Implemented in internal/cimd and internal/remotecache.
40. DPoP proof error codes
Protocol profiles and discovery
Spec RFC 9449 §7 defines the error code invalid_dpop_proof for a proof the server rejects, and §8 defines use_dpop_nonce for a proof that lacks the nonce the server requires.
Conflict /token, /par and the other form-post endpoints already answer every malformed request with the RFC 6749 §5.2 codes. A separate code for one header makes every relying-party library add a branch for DPoP alone, although its handling of a malformed proof is the same as for any other malformed request.
Decision
A form-post endpoint returns 400 invalid_request for a malformed, mis-signed, stale, replayed or mismatched proof.
The description names the failure in RFC 9449 wording; the underlying cause is not echoed.
Two codes stay as the RFC defines them, because a client acts on each differently:
- a missing or stale nonce returns
use_dpop_noncewith a fresh value in theDPoP-Nonceheader, so the client retries with it; - a protected resource that rejects a proof or a token presented under the wrong scheme returns
401withWWW-Authenticate: DPoP error="invalid_token"(see DPoP).
Implemented in internal/dpop.