ADR-036: CSRF protection uses the double-submit cookie pattern
Status: Accepted Date: 2026-04-21
Context
Every gyrum service with cookie-based authentication needs CSRF protection on state-changing endpoints. The two mainstream patterns are:
- Double-submit cookie. A random token is set in a cookie. Every
unsafe request must also echo the same token in a custom header
(
X-CSRF-Token). Server compares cookie and header in constant time. - Synchronizer token. Server stores a per-session token server-side, embeds it in every rendered form (or exposes it through an endpoint), and validates the submission against the server-side store.
The synchronizer pattern is stricter: a full server-side session store, with per-request tokens optionally rotated on use. Double-submit is simpler: no server state beyond the cookie, works with any frontend that can read a cookie and set a header.
gyrum services are already cookie-authed, already have JavaScript
frontends (SvelteKit), already deploy behind TLS with SameSite=Strict
as the default. Same-origin fetches read the cookie and set the header
trivially. Cross-origin forged requests cannot set custom headers
without a CORS preflight — and pkg/middleware.CORS is allow-list by
default. The attack surface double-submit protects against is exactly
the residual risk we have.
Decision
pkg/security.CSRF implements the double-submit cookie pattern:
- Cookie
csrf_token(overridable), value is 32 bytes of crypto/rand, base64url-encoded. SameSite=Strict,Secure=true,HttpOnly=false(must be readable by JS to echo in header),Path=/.- Unsafe methods (POST/PUT/PATCH/DELETE) require both the cookie and
the
X-CSRF-Tokenheader to be non-empty and equal undersubtle.ConstantTimeCompare. Safe methods pass through. - Mismatch returns 401 JSON.
Consequences
Easier:
- Zero server-side state for CSRF. Services that don't already have a session store don't need one.
- The pattern is well-understood by frontend developers — OWASP documentation describes it as the "stateless CSRF defence".
- Token rotation is trivial: call
IssueCSRFTokenon every login, and (optionally) on privilege escalation.
Harder:
- The cookie is JavaScript-readable. An XSS bug leaks the token. We
accept this because (a)
SameSite=Strictmeans the cookie only rides on same-origin requests so the attacker has to be same-origin anyway, and (b) an XSS attacker on a logged-in page doesn't need the CSRF token — they can issue requests directly as the user. - A rotated token mid-session breaks in-flight requests. Services must only rotate on credential-change events, not on every request.
Signed up to operate:
pkg/security.CSRFis mandatory for every service that uses cookie-auth and has unsafe-method endpoints. The security standards doc references this ADR.
Alternatives considered
- Synchronizer token — rejected for now. Requires a session store we don't universally have. Heavier to operate; no meaningful upside against a same-site-strict + CORS-allowlist baseline.
- Origin / Referer header check only — rejected. Works for browser requests but leaks no signal from native clients; some privacy browsers strip Referer even on same-origin. Double-submit needs no Referer and fails closed on mobile/native clients that don't set the header.
- Custom header alone (no cookie) — rejected. Depends on the frontend consistently setting the header; any request forgotten in migration is unprotected. Double-submit's cookie side ensures the middleware can always tell when the header is MISSING vs merely wrong.
Supersedes: none Superseded by: —