ADR-040: OIDC callback cookie uses SameSite=Lax, not Strict
- Status: Accepted
- Date: 2026-06-22
- Authors: Adry
- Code: pkg/server/auth_routes.go, pkg/auth/oidc/connector.go
Context
OIDC login starts on iterion, redirects the browser to an identity provider, and returns through a cross-site top-level GET callback. Iterion needs the OAuth/OIDC state value for freshness and uniqueness, but state alone does not bind the flow to the browser that initiated it.
A user-agent binding cookie protects against login CSRF/session fixation, where an attacker starts a flow in their browser and tricks a victim into completing the callback. That cookie must arrive on the IdP callback, which makes SameSite=Strict too restrictive for the normal OIDC navigation.
Decision
The auth routes set an HttpOnly iterion_oidc_agent cookie with SameSite=Lax in pkg/server/auth_routes.go. Comments at the start and callback paths document that Lax is required because the callback is a top-level GET from the IdP.
The flow also stores server-side pending auth keyed by the random state token generated by pkg/auth/oidc/connector.go. The callback must present the IdP-echoed state and the matching user-agent binding cookie; the cookie does not replace state, and state does not replace the cookie.
Path scoping and HttpOnly reduce unrelated exposure, while Lax preserves the browser behaviour needed for the cross-site callback.
Trade-offs
| Dimension | HttpOnly SameSite=Lax cookie bound to state | SameSite=Strict cookie | State only | |---|---|---| | IdP callback compatibility | Sent on cross-site top-level GET callback. | Usually blocked on the callback. | Compatible. | | Login-CSRF resistance | Binds state to initiating user agent. | Strong cookie boundary but breaks flow. | Does not bind browser to flow. | | Complexity | Requires cookie plus server-side state check. | Same complexity but unusable callback. | Simpler. |
The honest concession is that the design relies on browser Lax semantics continuing to include top-level GET navigations.
Alternatives considered
1. Use SameSite=Strict
The agent-binding cookie could have been Strict to avoid cross-site cookie sends.
Rejected because: OIDC callbacks are cross-site top-level navigations from the IdP, and Strict would prevent the binding cookie from arriving on legitimate callbacks.
2. Rely on OAuth/OIDC state alone
The callback could have verified only the random state stored server-side.
Rejected because: state proves freshness/uniqueness but does not bind the flow to the user agent that initiated it, leaving login-CSRF/session-fixation risk.
Consequences
- OIDC callbacks work in normal browsers. The binding cookie is sent on the IdP's top-level GET redirect back to iterion.
- Login CSRF is mitigated beyond state. The callback must match both server-side state and the browser-held agent binding.
- Cookie scope is intentionally narrow. The cookie is HttpOnly and path-scoped to the OIDC route family.
- The design depends on Lax semantics. Browser changes to SameSite=Lax callback behaviour could break or weaken the flow.
- Rechallenge if browser behaviour changes. If Lax stops covering this callback shape, the binding mechanism should be redesigned.
