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.

html
<!-- 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

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

http
Set-Cookie: session=…; HttpOnly; Secure; SameSite=Lax

Lax 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.

js
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

Disadvantages

Checklist

Sources

Read this lesson as Markdown