CSRF and How to Stop It
The attack that exists precisely because browsers attach cookies automatically, and the three defences that stop it.
Cross-Site Request Forgery works like this: a victim is logged into bank.com. They visit evil.com, which contains a form that posts to bank.com/transfer. The browser sends the request with the victim's session cookie attached, because that is what browsers do. The bank sees a perfectly authenticated request to move money.
Note what the attacker never needs: they cannot read the response, they do not know the session id, and they do not need to. They only need the side effect.
Why only cookies are affected
Because cookies are attached ambiently. A bearer token is attached by your JavaScript, and evil.com's JavaScript has no access to your token, so it cannot construct an authenticated request. CSRF is a cookie problem.
Defence 1 — SameSite
SameSite=Lax (now the browser default) blocks cookies on cross-site POSTs and cross-site fetches. That single attribute kills the classic form attack. It is necessary but not sufficient: it does not help if your cookie must be SameSite=None, and it is a browser behaviour rather than something your server verified.
Defence 2 — CSRF tokens
Two variants:
- Synchroniser token — the server stores a random token in the session and embeds it in the page. Requests must echo it. Strongest, needs server state.
- Double-submit cookie — the server sets a random value in a readable cookie; the client copies it into a header. The attacker can cause the cookie to be sent but cannot read it to build the header. Stateless and the common choice for SPAs.
Defence 3 — require a custom header
A cross-site request carrying X-Requested-With or any custom header triggers a CORS preflight. If your API does not allow that origin, the browser never sends the real request. Cheap, and it composes with the others.
Also check the origin
Verify the Origin header on state-changing requests against an allowlist. It is set by the browser and cannot be spoofed by page JavaScript.
And never mutate on GET
GET /delete-account?id=7 is CSRF-able by an <img> tag. Safe methods must be safe.
Example
<!-- What the attacker hosts on evil.com. No JavaScript needed. -->
<form id="f" action="https://bank.com/transfer" method="POST">
<input type="hidden" name="to" value="attacker">
<input type="hidden" name="amount" value="5000">
</form>
<script>document.getElementById('f').submit();</script>
<!-- Even simpler, if the API foolishly mutates on GET: -->
<img src="https://bank.com/transfer?to=attacker&amount=5000" hidden>
<!-- Both fail against: SameSite=Lax + a CSRF token + an Origin check. -->When to use it
- A banking API requires a CSRF token on every state-changing route, so even a SameSite=None cookie set for an embedded widget cannot be abused from another site.
- An SPA reads a readable XSRF-TOKEN cookie and echoes it in a header, giving stateless double-submit protection with no server-side token storage.
- A code review catches a DELETE implemented as a GET route, which would have been triggerable by an image tag in any forum post.
More examples
Double-submit CSRF, both halves
The attacker's page can make the browser SEND the XSRF-TOKEN cookie, but the same-origin policy stops it READING the value to build the matching header. That asymmetry is the whole trick.
import { randomBytes, timingSafeEqual } from 'crypto';
// --- server: issue a READABLE token cookie alongside the HttpOnly session ---
function issueCsrf(res) {
const token = randomBytes(32).toString('base64url');
res.cookie('XSRF-TOKEN', token, {
httpOnly: false, // the frontend must be able to read this one
secure: true,
sameSite: 'lax',
path: '/',
});
return token;
}
// --- server: verify on every state-changing request ---
const SAFE = new Set(['GET', 'HEAD', 'OPTIONS']);
const ALLOWED_ORIGINS = new Set(['https://abc.com']);
export function csrf(req, res, next) {
if (SAFE.has(req.method)) return next();
// Origin is set by the browser and cannot be forged by page scripts.
const origin = req.get('origin');
if (origin && !ALLOWED_ORIGINS.has(origin)) {
return res.status(403).json({ error: 'bad_origin' });
}
const cookieToken = req.cookies['XSRF-TOKEN'] || '';
const headerToken = req.get('x-xsrf-token') || '';
const a = Buffer.from(cookieToken), b = Buffer.from(headerToken);
if (!a.length || a.length !== b.length || !timingSafeEqual(a, b)) {
return res.status(403).json({ error: 'csrf_token_mismatch' });
}
next();
}
// --- client: copy the cookie into the header ---
const readCookie = (name) =>
document.cookie.split('; ').find((c) => c.startsWith(name + '='))?.split('=')[1];
await fetch('https://dfg.com/api/transfer', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': decodeURIComponent(readCookie('XSRF-TOKEN') || ''),
},
body: JSON.stringify({ to: 'bob', amount: 10 }),
});Framework CSRF, and the routes people exclude
Excluding api/* from CSRF is safe only if those routes are not cookie-authenticated. Teams routinely exclude the routes and later add cookie auth to them.
<?php
// Laravel ships this by default for web routes; Sanctum extends it to SPAs.
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: [
'webhooks/*', // ✅ fine: signed with HMAC, no cookie involved
// 'api/*', // ❌ NOT fine if api/* is authenticated by a cookie
]);
})
// Sanctum's SPA flow: the frontend first hits this route, which sets the
// XSRF-TOKEN cookie, then sends it back as the X-XSRF-TOKEN header.
// GET /sanctum/csrf-cookie
// Rule of thumb:
// authenticated by cookie → CSRF protection REQUIRED
// authenticated by bearer → CSRF protection unnecessary
// authenticated by HMAC → CSRF protection unnecessary
Discussion