Web Platform · Web Security Model

CSRF Defence: Synchronizer Tokens, Fetch Metadata, and SameSite

Why ambient credentials make state-changing requests forgeable, what a synchronizer token must be bound to, and how Sec-Fetch-Site lets a server reject cross-site writes without per-form state.

The same-origin policy stops a page from reading another origin’s responses. It does not stop the page from causing requests, and the browser attaches cookies to those requests automatically. Cross-site request forgery is the consequence: an attacker who can get a victim’s browser to issue a request to a target site inherits the victim’s session for the duration of that request. The attacker never sees the response, but a state change needs no response.

This is why CSRF defence cannot be a read check. It has to answer a different question from CORS: not “may this origin read the result” but “did the request originate from a context the server intended”.

The request shapes an attacker can produce

The set of producible requests is wide but not unlimited, and the limits set the defence.

A cross-site <form> can issue GET and POST with the encodings application/x-www-form-urlencoded, multipart/form-data, and text/plain. A form cannot set arbitrary headers and cannot send application/json. An <img>, <script>, <link>, <video>, or <iframe> can issue a GET with a URL of the attacker’s choosing. fetch can issue any method and any header, but a non-simple request triggers a preflight that a target without CORS headers will not grant, so the real request is never sent.

Two consequences follow directly. First, any state change reachable by GET is forgeable with no token and no preflight: a cross-site subresource GET carries the cookie when SameSite is None, and a top-level GET navigation carries it under both Lax and None. Only Strict withholds it. Safe methods must be genuinely safe, which RFC 9110 defines as no state change beyond logging and similar bookkeeping. Second, a JSON API that insists on Content-Type: application/json is already resistant to form-based forgery, because the form encodings cannot produce that content type and the browser will not let a cross-site script set it without a preflight. That resistance disappears the moment the handler parses a body regardless of the declared type, a common pattern in frameworks that fall back to form encoding when JSON parsing fails.

text/plain forms deserve a specific mention. A form field name is not restricted to identifier characters, so an attacker can submit a field named {"amount":1000,"to":"attacker"} with an empty value and a body that is nearly valid JSON. A parser that tolerates trailing characters, or one that concatenates field names, can be made to accept it. Rejecting requests whose content type is not exactly the expected one closes this.

Synchronizer tokens and their binding requirements

The synchronizer token pattern places a random value in the form and in the session, and the server compares them on submission.

<form method="post" action="/transfer">
  <input type="hidden" name="csrf" value="7f4c1e9a0b3d6f28">
  <input name="amount" inputmode="decimal">
  <button type="submit">Send</button>
</form>

The token needs three properties. It must be unpredictable: at least 128 bits from a cryptographic generator, not a hash of the session identifier or a counter. It must be bound to the session it was issued for, so that a token harvested from the attacker’s own session does not authorise a request in the victim’s. It must be verified before any side effect, with a constant-time comparison, and the check must not be skippable when the parameter is absent — an implementation that treats a missing token as “not applicable” is the most common way this defence fails.

Per-session tokens are the usual compromise, because they survive multiple tabs and page reloads. Per-request tokens are stronger against token theft through referrer leakage or logs but require either a stash of issued tokens per session or a form of stateless derivation, and they break back-button resubmission. Tokens must travel in a body field or a custom header, never in the URL, because URLs land in Referer, in browser history, and in server access logs.

The token’s placement in the page brings its own requirement: the page that renders it must not be cacheable by a shared cache, and the token must not be readable by third-party script. A token in a HttpOnly cookie is not a synchronizer token at all, since the server cannot distinguish a value the page sent from one the browser attached.

Login deserves separate treatment. If the login form is not CSRF-protected, an attacker can log the victim into an account the attacker controls — login CSRF — and any data the victim then enters is visible to the attacker. The session identifier must also be regenerated at login; reusing a pre-authentication identifier is session fixation, and it makes CSRF protection on the login form irrelevant because the attacker already knows the post-login identifier.

Double-submit cookies and when they break

The double-submit pattern avoids server-side token storage by putting the random value in a cookie and requiring the same value in a request field or header. The server compares the two; a cross-origin attacker can cause the cookie to be sent but cannot read it, so cannot reproduce it in the body.

The pattern’s security depends entirely on the attacker’s inability to write cookies for the target site. Any subdomain that can be induced to set Set-Cookie for the parent domain can plant a known value, and the comparison then passes. The same is true for a site with an open redirect that reflects headers, or with a path where an unauthenticated response sets cookies. A signed double-submit — the cookie holds a random value plus an HMAC over it keyed by a server secret, or the value is bound to the session identifier — restores most of the guarantee, because the attacker can plant a cookie but cannot forge the MAC.

The honest comparison: a synchronizer token costs a session store lookup and complicates multiple tabs; a signed double-submit costs a secret and is stateless. An unsigned double-submit is a token-shaped check with a real bypass, and it is often deployed as though the cookie were protected when it is not.

Using the browser’s own classification

Fetch metadata headers let the server see how the browser classified the request. Sec-Fetch-Site takes one of same-origin, same-site, cross-site, or none (a navigation the user initiated directly, from the address bar or a bookmark). Sec-Fetch-Mode takes values such as navigate, cors, no-cors, and same-origin. Sec-Fetch-Dest names the destination, such as document, image, or script. Sec-Fetch-User is ?1 when the request was triggered by user activation.

These headers are set by the browser and are forbidden header names, so script cannot forge them. A server can therefore reject any state-changing request whose Sec-Fetch-Site is cross-site, without per-form state:

function rejectCrossSiteWrite(req, res, next) {
  if (req.method === 'GET' || req.method === 'HEAD') return next();
  const site = req.get('sec-fetch-site');
  if (site === 'cross-site') return res.sendStatus(403);
  return next();
}

The none value needs a decision. It covers top-level navigations the user initiated directly, from the address bar or a bookmark. A request from a non-browser client carries no header at all. Treating an absent header as allowed keeps API clients working; treating it as cross-site is stricter and breaks them. Either choice must be deliberate, and the check must be paired with a positive authentication check so that a same-site but compromised sibling origin is still constrained by its own authorization.

The header-based check is not a complete replacement for tokens, because it trusts the browser’s classification and because it does not help browsers that omit the headers. It is, however, much harder to get wrong than per-form state, it covers JSON endpoints and multipart uploads uniformly, and it composes with SameSite rather than duplicating it. The check belongs in middleware, before body parsing, so that a rejected request never reaches a handler with partial side effects.

Reconciling SameSite with the routes that need cross-site

SameSite=Lax blocks the cross-site POST, which is the classic CSRF shape, and it does so without any server code. What it does not block is a cross-site top-level GET navigation, which is precisely the case where a state-changing GET is exploitable. It also does not block same-site requests, including requests from a sibling subdomain that has been compromised or taken over — and a sibling subdomain is, for cookie purposes, the same site.

The route that genuinely needs SameSite=None is the one that must work in a cross-site context: a payment provider’s callback, an identity provider’s assertion POST, an embedded widget. None requires Secure, and it restores the full CSRF surface for that endpoint, so such a route needs a token or a fetch-metadata check of its own. A useful split is Strict on administrative surfaces where a first navigation arriving logged-out is acceptable, Lax on ordinary application pages, and None only where a documented cross-site flow requires it, with token verification mandatory there.

SameSite also interacts with the two-minute grace window described in cookie scoping and the site boundary: a cookie set very recently may accompany a cross-site top-level POST, so a freshly issued session is not protected by Lax alone. Token checks have no such window.

The combination that holds up is boring: safe methods stay safe; state changes are POST or another non-safe method with an exact content-type check; a synchronizer token or a session-bound MAC is verified before any effect; Sec-Fetch-Site: cross-site is rejected for writes; and SameSite is set per route rather than globally. Each layer covers a different failure of the others, and none of them requires trusting the Origin header, which older browsers omit for same-origin GET and for some form posts. Frame-based flows need one more consideration — the sandbox and postMessage rules in sandboxed iframes and the postMessage boundary — because a form inside a sandboxed frame carries the frame’s opaque origin, not the embedder’s.

References

  1. RFC 9110: HTTP Semantics — Safe Methods — IETF
  2. Fetch Metadata Request Headers — W3C
  3. OWASP Cross-Site Request Forgery Prevention Cheat Sheet — OWASP Foundation