# 0008 — A per-session CSRF token on every form, as the third layer

**Status:** accepted · **Date:** 2026-09-08

## Context

Steid has thirty-eight POST routes, thirty-one of which are forms a signed-in owner
submits with an ambient session cookie. Cross-site request forgery is the classic hole
under that shape, and `current.md` had been carrying it as decidable-not-decided since
the forms first existed.

Two defences were already in place, and finding the second is what made this a question
about *depth* rather than about coverage:

- The session cookie is `SameSite=Lax`, which stops a cross-site POST in every current
  browser.
- Topcoat's `sessions()` silently installs an `OriginLayer` — read in
  `topcoat-session-0.5.0/src/origin.rs`, not assumed — which refuses any non-safe method
  whose `Sec-Fetch-Site` is not `same-origin`/`none`, falling back to comparing
  `Origin`'s host against the request's own.

Both have the same gap, and it is deliberate in the layer: **a request carrying neither
header passes**, because that is what a non-browser client looks like and a non-browser
client has no ambient cookies to forge with. A browser old enough to send neither is in
that gap, and so is a proxy that strips `Origin`. And `SameSite=Lax` is one line in
`session_cookie.rs` that somebody could loosen for an unrelated reason.

There is also no configured session secret to derive anything from: `main` builds
`SessionConfig::default()`.

## Decision

**Every form carries a hidden `csrf_token` of `HMAC-SHA-256(per-boot secret, session
token hash)`, and every guarded POST handler verifies it in constant time before its use
case. A missing or wrong token is a 403 with one sentence. The secret is random per
boot.**

Two exemption classes, enumerated with reasons in `csrf::EXEMPT` and asserted by a test:
routes with no cookie and no form (the git transport, `/api/setup`), and routes
submitted signed out (`/auth/setup`, `/auth/login`, `/auth/forgot`,
`/auth/reset/{token}`), which have no session to bind a token to.

The obligation this creates is enforced by a test that reads
`src/infrastructure/web/*.rs` and fails when a POST route neither calls the check nor is
named in `EXEMPT`.

## Alternatives considered

- **Nothing — rely on `SameSite=Lax` and the origin layer.** Cheapest, and correct for
  every browser anyone actually uses. Rejected because both defences are one
  configuration line from being off, both share a gap they cannot close by design, and
  the cost of closing it is one hidden input.
- **A random token stored in the session row.** The conventional shape, and it needs a
  column, a write on first render, and a decision about what happens when two tabs race.
  The HMAC gets the same binding out of state that already exists on both sides.
- **A token derived from the session secret rather than random per boot.** Would survive
  a restart, which is the one real cost of this choice. There is no session secret to
  derive from, and inventing a persisted one is a bigger decision than this — it becomes
  a thing to back up, rotate and configure.
- **A second CSRF cookie, so signed-out forms could be protected too.** The honest fix
  for login CSRF, and rejected as scope: it is a cookie with its own lifetime and
  `SameSite` question, and it protects `/auth/login` alone.

## Consequences

- **A restart invalidates every form already open on someone's screen.** Submitting one
  answers 403 and the person reloads and retypes. Acceptable for a form and not for a
  session, which is why the session cookie is not treated the same way. It bites hardest
  in development, where `topcoat dev` restarts on every edit.
- **Every new form is now three things, not two**: the field, the struct field, and the
  check. The scan test is what makes forgetting the third a failing build rather than a
  silent hole, and it is the only reason this is safe to spread across thirty-one
  handlers.
- **Login CSRF is still open**, and knowingly. An attacker can force a victim's browser
  to sign in as the attacker; `SameSite=Lax` and the origin layer still cover it in any
  current browser, and closing it properly needs a pre-session cookie.
- **Reversing is cheap**: delete the module, the field, and the calls. Nothing is stored
  and no URL or schema depends on it.
- **No new dependency.** `hmac`, `sha2`, `subtle` and `rand` were all already here.
