The Runtime Theory
Security

CSRF Tokens and SameSite Cookies: Defending the State-Changing Request

Why CSRF attacks work, why GET mutations are the root sin, and how double-submit tokens and SameSite cookies actually stop forged requests.

The Runtime Theory Team4 min read#csrf#same-site-cookies#csrf-tokens#web-security#session-security
On this page

Cross-Site Request Forgery exploits a property of the web's default behavior: browsers attach cookies to requests without asking whether the site that initiated the request was allowed to. If a victim is logged in to bank.example, any page they visit can forge a request to bank.example/transfer — and the bank's server cannot distinguish that request from one the user made themselves, because the session cookie arrives either way. This article covers the attack mechanics, the two real defenses, and the architectural sin that makes CSRF possible.

How a Forged Request Actually Happens

The victim is authenticated; the attacker's page just needs to trigger a browser navigation. The simplest weapon is an image tag:

html
<img src="https://bank.example/transfer?to=attacker&amount=10000">

The browser requests the URL with the bank's cookies attached (cross-site requests historically sent cookies unconditionally). A GET with side effects — the "GET mutation" sin — completes the transfer. No JavaScript required, no payload parsing: the browser itself is the exploit delivery vehicle.

More robust attacks use forms, because forms can send POST with any encoding:

html
<form action="https://bank.example/transfer" method="POST" id="f">
  <input type="hidden" name="to" value="attacker">
  <input type="hidden" name="amount" value="10000">
</form>
<script>document.getElementById("f").submit();</script>

The victim's browser sends the POST, cookies included. The server checks the session, sees a valid authenticated user, and executes. The server never learns the request was not initiated by the user — that is the whole attack.

The Root Sin: GET Mutations

If state changes only happen on POST/PUT/DELETE, the <img> vector dies instantly, and form-forged POSTs remain. GET-with-side-effects is a violation of HTTP semantics (GET should be safe: no server-side state change) and it is the reason CSRF attacks historically needed no HTML forms at all. Audit your routes: any endpoint that mutates state on GET is a CSRF hole regardless of every other control you deploy.

Defense 1: CSRF Tokens

The classic defense: the server issues a random, unguessable token bound to the session, embeds it in every state-changing form, and rejects requests without it.

html
<form action="/transfer" method="POST">
  <input type="hidden" name="csrf_token" value="f3a9...">
  ...
</form>

The attacker's forged form cannot include the token — the attacker never sees it (same-origin policy blocks reading it), and guessing a 128-bit random token is infeasible. The synchronizer token pattern stores the token server-side and compares; the double-submit pattern sends the same random value in a cookie and the body, and the server verifies they match — stateless, at the cost of trusting that the attacker cannot set a matching cookie pair (a separate subdomain injection can break this).

Defense 2: SameSite Cookies

SameSite is a cookie attribute, set at issuance, that tells the browser whether to attach the cookie to cross-site requests:

http
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax
  • Strict: never send the cookie cross-site. Strongest, but breaks legitimate cross-site navigation (links from email, other apps) — users get "logged out" clicks.
  • Lax: send the cookie on top-level navigations (user clicking a link) but not on subresource requests or POSTs from other origins. Kills the <img>/<form> POST vectors while preserving normal link-in navigation.

SameSite=Lax is the modern default (Lax is the default in Chrome and Safari for cookies without the attribute), and it neutralizes the overwhelming majority of CSRF for cookie-based sessions. Cross-site POSTs with SameSite=Lax cookies are simply not authenticated.

What Still Needs Tokens

SameSite is not a silver bullet:

  • Subdomain takeover or sibling-subdomain XSS: an attacker-controlled subdomain is still "same-site," so the cookie attaches — tokens bound to the origin survive this, SameSite does not.
  • Non-cookie auth (Basic auth, TLS client certs, custom headers): SameSite governs cookie transmission only.
  • Top-level navigation tricks: some GET-mutation endpoints are still reachable via Lax navigations, which is why tokens remain the defense of record.

Other Layers That Matter

  • Custom header checks: requiring a non-simple header like X-Requested-With blocks some cross-origin requests (they trigger preflight), but it is a policy check, not a secret — treat it as a speed bump, not a control.
  • Origin/Referer validation: rejecting requests whose Origin is not your host is a legitimate defense for API endpoints that must accept tokens differently, though referers are stripped in some privacy contexts — never rely on Referer alone.
  • SameSite on all cookies: session, auth, preference cookies alike. One cookie left without the attribute reopens the vector.

Verdict

CSRF exists because the browser, not the server, decides when cookies travel — and the server historically had no way to distinguish a user's intent from a subresource's demand. The defense is layered: no GET mutations (the root sin), synchronizer tokens for state-changing requests, and SameSite cookies as the browser-level tripwire. Implemented together, a forged request arrives without credentials, without a token, or both — and every one of those arrivals is rejected.