Learn / Security / Lesson 02
CSRF — cross-site request forgery
How another website can act as your signed-in user, why cookies make it possible, and the three modern defences.
Last updated: 2026-09-19
What it is
A browser attaches your cookies to every request it sends to a site, no matter which page started the request. So if you are signed in to your bank and visit a hostile page, that page can make your browser send a request to the bank, and the bank sees your valid session.
That is CSRF: the attacker cannot read the answer, but they do not need to. They only need the action to happen.
<!-- on evil.example -->
<form action="https://bank.example/transfer" method="POST">
<input type="hidden" name="to" value="attacker">
<input type="hidden" name="amount" value="50000">
</form>
<script>document.forms[0].submit()</script>Why it is a rule
- Netflix (2006). A researcher showed that a hostile page could change a signed-in member's shipping address and add films to their queue. Netflix fixed it after the report.
- ING Direct, YouTube, The New York Times (2008). Zeller and Felten at Princeton found CSRF holes in all of them. On ING Direct, a hostile page could open a new account in the victim's name and transfer money out of their account. On YouTube it could act as the user on almost every feature.
Nothing was hacked on the server in any of these. The site simply trusted a cookie, and the cookie came along for the ride.
How to do it
Use these together. Each covers a gap the others leave.
1. Never change data on a GET
Links, images and prefetches all send GETs. A GET /delete?id=5 can be triggered by an <img> tag. Writes go on POST, PUT, PATCH and DELETE.
2. SameSite cookies
Set-Cookie: session=…; HttpOnly; Secure; SameSite=LaxLax stops the cookie on cross-site POSTs. It does not stop it on top-level GET navigations, and it treats every subdomain of your site as "same site".
3. Check where the request came from
Every current browser sends Sec-Fetch-Site on every request. A write is allowed only when it says same-origin (or none, for something the user typed). Older browsers fall back to comparing the Origin header to your own host.
const WRITES = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
const sameOrigin = (req) => {
const site = req.get('sec-fetch-site')
if (site) return site === 'same-origin' || site === 'none'
const origin = req.get('origin')
if (!origin) return true
try { return new URL(origin).host === req.get('host') } catch { return false }
}
app.use((req, res, next) => {
if (!WRITES.has(req.method) || sameOrigin(req)) return next()
res.status(403).json({ error: 'Cross-site request blocked' })
})A request with neither header is a script or a server, not a browser. It carries no victim's cookies, so there is nothing to forge, and the route still checks its own login.
4. Tokens, the classic way
A random token stored in the session and printed into every form. The server rejects a write whose token does not match. Still valid, and still what OWASP lists first, but it needs plumbing into every form and every fetch.
How we do it here
Session cookies are HttpOnly, Secure and SameSite=Lax. On top of that, every write is refused unless the browser says it came from our own origin, and same-site is refused too, so a subdomain cannot write with a user's cookies. The only routes allowed to take writes from other sites are ones that authenticate with their own key and never read a session.
Benefits
- Fetch Metadata and Origin checks are one middleware, with no per-form plumbing.
- They cover every route at once, including ones added later.
- Layered with SameSite, a gap in one is covered by the other.
Disadvantages
- Real cross-site integrations (webhooks, embeddable widgets) need explicit exemptions, and every exemption is a place the guard does not look.
- Very old browsers send neither header; they rely on SameSite and on the route's own checks.
- It does nothing for a GET that changes data. That has to be fixed in the route.
- Tokens, if you use them, break on cached pages and need care with multiple tabs.
Checklist
- No route changes data on a GET.
- Session cookies are
SameSite=LaxorStrict. - Writes from another origin are refused.
- Each exemption is listed, justified, and never reads a session.
Sources
- OWASP — CSRF Prevention Cheat Sheet
- web.dev — Protect your resources from web attacks with Fetch Metadata
- MDN — SameSite cookies
- William Zeller and Edward Felten, Cross-Site Request Forgeries: Exploitation and Prevention, Princeton, 2008.