What you'll learn
Quick Answer
CSRF tricks a logged-in user's browser into sending a state-changing request to your site from an attacker's page. It works because browsers attach cookies based on the destination, not the origin of the request, so the attacker never needs to read the response. Defend with SameSite=Lax cookies plus a synchroniser CSRF token on anything that writes data. APIs that authenticate with an Authorization header are structurally less exposed, since that header is never attached automatically.
The attack, start to finish
Cross-site request forgery works because a browser sends your cookies to a site based on where the request is going, not on where it came from. The attacker never needs to read anything. They only need your browser to perform an action while it is logged in.
The setup is ordinary. You log into your college portal or a payments dashboard in one tab. The session cookie is now stored for that domain. In another tab you open a page somebody shared, and that page contains this:
<form action="https://bank.example/transfer" method="POST" id="f">
<input type="hidden" name="to" value="attacker-account">
<input type="hidden" name="amount" value="50000">
</form>
<script>document.getElementById('f').submit();</script>The form submits itself the moment the page loads. Your browser sends the POST to the bank, and because the request is going to bank.example, it attaches the bank session cookie exactly as it would if you had clicked the button yourself. The bank sees an authenticated request with valid parameters and processes it.
The attacker page cannot read the response. Same-origin policy blocks that, and it does not matter: the transfer already happened. This is the part people get wrong when they first meet CSRF. They look for the data-theft step and conclude the attack is weak. There is no data-theft step. The value is the side effect.
It also does not need a form. An image tag pointing at a state-changing GET endpoint is enough, which is one reason GET /delete?id=5 is a bad idea even inside an admin panel. Anything that causes the browser to issue a request will do.
Why cookies are the enabling mechanism
The enabling property has a name: ambient authority. A cookie is attached automatically by the browser to every request to its domain, with no involvement from the page that triggered the request. Your application code never chooses to send it. That automatic attachment is what makes cookies convenient and what makes CSRF possible.
Two misconceptions are worth clearing up because both come up in interviews.
CORS does not stop this. CORS governs whether JavaScript on one origin may read a response from another. A plain HTML form submission is not JavaScript reading anything, so CORS never enters the picture. Forms can send application/x-www-form-urlencoded, multipart/form-data and text/plain cross-origin with no preflight request at all.
JSON-only APIs get partial protection by accident. A fetch with Content-Type: application/json is not a simple request, so the browser sends a preflight OPTIONS first and refuses to send the real request unless your server allows that origin. That is real, but it is a side effect rather than a control. If your server also parses form-encoded or text bodies for the same route, a plain form reaches it and the protection evaporates.
There is a matching consequence on the other side. If your API authenticates with a header your JavaScript sets explicitly, nothing is attached automatically, and the attacker page has no way to set it. That is the structural reason header-based auth is less exposed, covered further below.
SameSite cookies and what they miss
The SameSite cookie attribute tells the browser when it may attach a cookie to a cross-site request. It is the cheapest meaningful defence available.
SameSite=Strictmeans the cookie is never sent on any cross-site request, including a normal link. The side effect is real: a user clicking a link from an email to your dashboard arrives logged out, then refreshes and is logged in, which looks broken.SameSite=Laxmeans the cookie is sent on top-level navigations that use a safe method, essentially clicking a link. It is not sent on cross-site POST submissions, iframe loads, or background fetches. That covers the classic auto-submitting form while keeping links usable.SameSite=Nonemeans the old behaviour, and browsers requireSecurewith it. Use it only when you genuinely need cross-site cookies, such as an embedded widget.
Set-Cookie: sid=abc123; HttpOnly; Secure; SameSite=Lax; Path=/Chromium-based browsers treat a cookie with no SameSite attribute as Lax by default, and other browsers have handled the default differently over time. Set the attribute explicitly rather than relying on a default you cannot control.
Now the gaps, because "we set SameSite=Lax" is not a complete answer:
- Same-site is not same-origin. If an attacker controls any subdomain of your registrable domain, requests from it are same-site, and the cookie goes along. Subdomain takeovers of forgotten staging hosts are a real path here.
- Lax still allows top-level GET. Any endpoint that changes state on GET remains reachable. Keep GET read-only.
- Freshly set cookies. Chrome has shipped a compatibility behaviour where a cookie created a couple of minutes ago is still attached to a cross-site top-level POST, so "Lax blocks cross-site POST" is not quite absolute. The details have moved between versions, which is one more reason not to lean on the attribute alone.
- Older or unusual clients. A user on an outdated browser may not enforce it, and you do not control which browser someone uses.
Treat SameSite as a strong default that reduces the attack surface, and keep a token for anything that moves money, changes credentials or deletes data.
CSRF tokens done properly
The synchroniser token pattern is the standard defence. Your server generates a random value, stores it in the session, and embeds it in every form or sends it to the client for use in a header. On a state-changing request, the server checks that the value sent matches the value in the session. The attacker page cannot read your session or your HTML, so it cannot produce a matching value.
import crypto from 'crypto';
// Run this before rendering any page for a logged-in user
export function ensureCsrfToken(req, res, next) {
if (!req.session.csrf) {
req.session.csrf = crypto.randomBytes(32).toString('hex');
}
next();
}
function safeEqual(a, b) {
if (typeof a !== 'string' || typeof b !== 'string') return false;
const x = Buffer.from(a);
const y = Buffer.from(b);
if (x.length !== y.length) return false;
return crypto.timingSafeEqual(x, y);
}
export function checkCsrf(req, res, next) {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
const sent = req.get('X-CSRF-Token') || (req.body && req.body._csrf);
if (!safeEqual(sent, req.session.csrf)) {
return res.status(403).json({ error: 'CSRF check failed' });
}
next();
}Note that crypto.timingSafeEqual throws if the two buffers differ in length, so the length check has to come first. Note also that the token must come from a cryptographic random source; Math.random() is predictable and defeats the whole mechanism.
Common mistakes in real implementations:
- Checking the token only on POST while leaving PUT and DELETE unprotected.
- Generating a fresh token per request and getting confused when the user has two tabs open. Per-session is simpler and sufficient.
- Putting the token in a URL query string, where it leaks through referrers, browser history and server logs.
- Skipping the check on one convenient endpoint, usually the one an internal tool calls.
The double-submit variant sends the token both as a cookie and as a header, and compares the two without server-side storage. It is convenient for stateless services, but it assumes an attacker cannot set cookies on your domain, which fails if a subdomain is compromised. If you use it, sign the token so a value the attacker planted does not validate. A cheap extra layer for either approach is to reject state-changing requests whose Origin header is missing or does not match your site.
If your framework ships CSRF protection, use it. Django, Rails, Laravel and Spring Security all include a tested implementation, and the failure mode of hand-rolling this is silent.
Why an Authorization header API is less exposed
If your API authenticates through Authorization: Bearer <token>, the browser never attaches that header on its own. Your JavaScript sets it, and JavaScript on the attacker origin cannot read your token from another origin storage and cannot force the browser to add a header to a form submission. There is no ambient authority, so the classic CSRF shape simply does not apply.
await fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ to, amount }),
});This is a genuine structural advantage, and it is worth saying clearly in an interview: CSRF is a cookie problem, so removing cookies from the authentication path removes the problem. What it does not do is make the application safer overall, and there are four things that still bite.
- XSS defeats it completely. A script on your origin reads the token from wherever you keep it and uses it directly. Header auth trades a CSRF risk for a larger XSS blast radius, which is the real argument behind the localStorage debate.
- Mixed authentication. Many apps accept a bearer token and also accept a session cookie on the same routes, for the server-rendered pages. Any route that accepts the cookie needs CSRF protection, regardless of how the SPA talks to it.
- CORS misconfiguration. Reflecting whatever
Originthe request carries back intoAccess-Control-Allow-Originwhile settingAccess-Control-Allow-Credentials: truehands cross-origin JavaScript both send and read access. That is worse than CSRF because the attacker gets the response too. - State-changing GET. If a bearer token is not required for a route because it "only reads", but the route has a side effect, you have rebuilt the hole.
Practical guidance for a typical project: if you serve HTML pages with cookie sessions, use SameSite=Lax plus your framework CSRF tokens and keep GET read-only. If you have a separate API consumed by a single-page app or a mobile client, use header-based tokens, keep them out of long-lived readable storage where you can, and put your effort into XSS prevention instead. Mixing the two models in one codebase is where most real bugs live, because a route added later quietly inherits the wrong assumptions.
