Decisions

ADR-036: CSRF protection uses the double-submit cookie pattern

Every gyrum service with cookie-based authentication needs CSRF protection on state-changing endpoints. The two mainstream patterns are:

#036

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:

  1. 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.
  2. 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-Token header to be non-empty and equal under subtle.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 IssueCSRFToken on 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=Strict means 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.CSRF is 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: