CSRF & SameSite
Forged requests, anti-CSRF tokens, SameSite cookies.
CSRF & SameSite
Cross-site request forgery exploits one feature of browsers: they attach your cookies to requests automatically, regardless of which site initiated the request. That means a malicious page can cause the victim's browser to send an authenticated request to your site — as the victim — without the victim ever noticing.
Analogy
Picture a bank that recognises regulars by the coat they wear: show up in your familiar tweed jacket and the teller waves you through any transaction without checking ID. An impostor learns this, follows you home, waits until you hang your jacket on a public coat rack, and convinces a stranger to run your errands wearing it — "pop into the bank and transfer a thousand dollars to this account, please." The stranger is innocent, the jacket is genuinely yours, and the teller happily processes the transfer. CSRF is that whole scam: the cookie (your jacket) is auto-attached by the browser to any request headed for your bank's domain, regardless of who actually asked for it.
The attack, one sentence
An attacker gets the victim to load evil.com, which triggers a request to your-bank.example/transfer using the victim's logged-in session cookie.
Why GET mutations are always wrong
If GET /users/42/promote actually promotes user 42, an attacker can trigger that action by putting <img src="https://your-site/users/42/promote"> on any page the victim visits. Browsers prefetch images. The promotion happens.
The fix is not adding a CSRF token. The fix is moving the action to POST so browsers don't prefetch it. GET is supposed to be safe and idempotent — HTTP methods are not decoration.
Defense 1 — Anti-CSRF tokens
Your server generates an unpredictable token, stores it in the session, and embeds it in every form. On submission the server checks that the body contains the same token. An attacker site cross-origin can't read your site's responses, so it can't learn the token, so it can't include it.
<form method="POST" action="/transfer">
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
<input name="to"> <input name="amount"> <button>Send</button>
</form>
Every major framework has this built in — use it, don't write your own.
Defense 2 — SameSite cookies
SameSite tells the browser to only attach the cookie on same-site requests.
| Value | Behavior |
|---|---|
Strict |
Never send cross-site. Breaks some legitimate flows (e.g. clicking a link from email). |
Lax |
Send on top-level navigation (user typed URL, clicked a link). Block on POST/fetch/iframe. Default in modern browsers. |
None |
Send everywhere. Requires Secure. Use only if you genuinely need cross-site cookies. |
For most apps, Lax is the right default. Strict if you have no cross-site navigation.
Set-Cookie: sid=...; HttpOnly; Secure; SameSite=Lax; Path=/
Defense 3 — Custom header required
For XHR / fetch APIs, require a header that a cross-origin attacker can't set without CORS preflight (e.g. X-Requested-With or any non-simple content type). The browser's CORS preflight rules block the attacker from making the actual request.
This is why JSON API endpoints are often CSRF-safe by accident: a cross-origin page can't fetch(... { 'content-type': 'application/json' }) without triggering a preflight, and the preflight fails.
Defense 4 — Origin / Referer check
On every state-changing request, inspect the Origin header (or Referer as a fallback) and reject if it isn't your own origin. Cheap, but Referer can be stripped and some old browsers don't send Origin on POST — best as a secondary check.
What about Bearer tokens?
Authorization: Bearer <jwt> headers are not attached by the browser automatically. If your API is purely Bearer-auth'd (no cookies), it is not CSRF-vulnerable in the classic sense. The bug becomes a different one — storing the token in localStorage makes it visible to any XSS.
Putting it together
A cookie-based web app that cares about CSRF ships all of these:
- No GET endpoint ever mutates state. Ever.
- Session cookie:
HttpOnly,Secure,SameSite=Lax(orStrict). - Anti-CSRF token on every state-changing form submission.
- For fetch APIs: require
Content-Type: application/jsonor a custom header. - On sensitive routes, additionally check the
Originheader.
No single defense is enough on its own — they layer. If your framework gives you this by default, stay on the defaults.