---
title: CSRF — cross-site request forgery
summary: How another website can act as your signed-in user, why cookies make it possible, and the three modern defences.
topic: security
order: 2
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

- **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

```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

- 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=Lax` or `Strict`.
- Writes from another origin are refused.
- Each exemption is listed, justified, and never reads a session.

## Sources

- [OWASP — CSRF Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html)
- [web.dev — Protect your resources from web attacks with Fetch Metadata](https://web.dev/articles/fetch-metadata)
- [MDN — SameSite cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value)
- William Zeller and Edward Felten, *Cross-Site Request Forgeries: Exploitation and Prevention*, Princeton, 2008.
