cf.completefrontend
My progress
FUNDAMENTALS · MODULE 11

Cross-site request forgery (CSRF)

An attack where another site makes the user's own browser send a request to your app, with the user's cookies attached, so the app thinks the user asked for it. SameSite cookies, tokens, and origin checks stop it.

The problem: your browser acts for you

Maya logs in to her bank at bank.example in the morning. The bank sets a session cookie, sid=7f3a..., so she doesn't have to log in again on every page. She leaves the tab open and goes on with her day.

At lunch she clicks a link to a recipe blog at recipes.example. The page looks normal. Hidden inside it is a small HTML form that points at https://bank.example/transfer, with the fields to=attacker and amount=500, and one line of JavaScript that submits it as soon as the page loads.

Maya's browser sends the POST to the bank. Here is the problem: the browser attaches the bank's cookies to every request it sends to the bank, no matter which page started it. The bank sees a valid session cookie and a well-formed transfer request. As far as the bank can tell, Maya asked for it.

A sequence diagram between Maya's browser, recipes.example, and bank.example. The browser logs in to the bank and gets back Set-Cookie sid. Later it loads GET /pasta from recipes.example, which returns a page with a hidden form. The form submits itself, so the browser sends POST /transfer to the bank with the sid cookie attached. The bank answers 200 OK, 500 sent. recipes.example never sees the cookie or the reply.Maya's browserrecipes.examplebank.examplePOST /loginSet-Cookie: sidGET /pastapage + hidden formthe form submits itselfPOST /transfer + sid200 OK: 500 sentrecipes.example never sees the cookie or the reply
Every step is normal browser behaviour. The bank sees a valid cookie and has no way, from the cookie alone, to tell that Maya never chose to send this request.

This is cross-site request forgery, or CSRF (sometimes said "sea-surf" and sometimes written XSRF). One site forges a request to another site, and the user's own browser carries it, credentials and all. The attacker never sees Maya's password or her cookie. They don't need to.

Ambient credentials

The root cause has a name: ambient credentials. These are credentials the browser adds to requests on its own, without the page's code asking for them. Cookies are the main one. HTTP Basic auth that the browser remembers, and client TLS certificates, behave the same way.

Ambient credentials are convenient, which is why the web is built on them. They are also exactly what CSRF abuses. A credential the page has to add by hand, like an Authorization: Bearer ... header set from JavaScript, is not ambient: another site's page can't add it, because it can't read it.

  • Ambient: cookies, remembered Basic or Digest auth, client certificates, and on some networks, being inside the office network (an intranet router at 192.168.1.1 trusts anyone who can reach it).
  • Not ambient: a token kept in memory or in localStorage and added to each fetch by your own code. Another origin can't read it, so it can't send it. That doesn't make it safe from XSS, which can read it; see the XSS and client storage lessons.

What the attacker can and can't do

CSRF is a write attack. The attacker can make the browser send a request, but the same-origin policy stops their page from reading the response. So the damage is whatever a single blind request can do: move money, change the account email, add an admin, delete a repo, post a message, turn off two-factor login.

That limit shapes every defense in this lesson. If the server can require something in the request that only its own pages can know or produce (a secret token, a custom header, the right Origin), the forged request fails. And if the browser stops attaching the cookie to cross-site requests in the first place, there is nothing to forge.

Check your understanding · Exercise 1

Which of these would a forged request from another site carry automatically? Select all that apply.

The mental model: a trusted courier with no judgement

Think of the browser as a courier who carries a key to your house. The courier delivers any parcel addressed to your house, from anyone, and lets it in with the key. The house checks the key, sees it's valid, and accepts the parcel. Nobody checks who sent it.

Security people call this the confused deputy problem. A deputy (the browser) holds authority (the cookie) on behalf of someone (the user), and a third party (the attacker's page) tricks it into using that authority for the third party's goals. The deputy is not broken; it's doing what it was built to do.

So there are only two places to stop CSRF, and every defense lives in one of them:

  1. Make the courier smarter. Tell the browser not to hand over the key for parcels from strangers. That's the SameSite cookie attribute: the browser leaves the cookie off cross-site requests.
  2. Make the house check the sender. The server demands proof that the request came from its own pages. That proof can be a secret token only its pages know, a header only its own JavaScript can add, or the Origin and Sec-Fetch-Site headers that the browser fills in and pages can't fake.

Strong designs use both. The browser layer covers most attacks with no change to your pages; the server layer covers the gaps the browser layer leaves, and older or unusual browsers.

Same-site is not the same as same-origin

To use SameSite well you need two words that sound alike and mean different things. An origin is the triple of scheme, host, and port: https://app.bank.example:443. Two URLs are same-origin only if all three match exactly. This is the boundary for the same-origin policy, CORS, localStorage, and most of the browser's isolation.

A site is coarser: the scheme plus the registrable domain, which is the public suffix plus one label. For app.bank.example and cdn.bank.example the registrable domain is bank.example for both, so they are same-site even though they are different origins. For bank.example and bank-rewards.example the registrable domains differ, so they are cross-site.

The URL https://app.bank.example:443/inbox split into parts: scheme https, subdomain app, registrable domain bank.example, port 443, and path /inbox. The origin is scheme plus host plus port; the site is scheme plus registrable domain; the path belongs to neither. Below, four URLs compared with https://app.bank.example. app.bank.example/settings: same origin, same site. blog.bank.example: different origin, same site. http://app.bank.example: different origin and different site, because the scheme differs. bank-rewards.example: different origin and different site.registrable domainhttps://app.bank.example:443/inboxorigin: scheme + host + portpath:ignoredsite: scheme + registrable domainsame origin?same site?https://app.bank.example/settingsyesyeshttps://blog.bank.examplenoyeshttp://app.bank.examplenonohttps://bank-rewards.examplenono
The site keeps only the scheme and the registrable domain (the marked parts). So blog.bank.example is a different origin but the same site, and SameSite cookies go along with its requests.

The public suffix comes from the Public Suffix List, a list the browsers share of domains under which anyone can register names: com, co.uk, and also hosting domains like github.io and vercel.app. That's why alice.github.io and mallory.github.io are cross-site: github.io is itself a public suffix, so each user gets their own site.

The current cookie spec also compares the scheme when deciding same-site. This is called schemeful same-site, and Chromium browsers have applied it to cookies by default since Chrome 89 in 2021. So http://bank.example and https://bank.example are cross-site. A page served over plain HTTP can't pass itself off as a same-site neighbour of your HTTPS app.

Check your understanding · Exercise 2

Your app is at https://app.shop.example. Which of these pages are same-site with it? Select all that apply.

How an attack works, step by step

An attacker needs three things for CSRF to work. If any one is missing, the attack fails, and each defense in this lesson removes one of them.

  1. A state-changing action the target app performs on a single request, like POST /transfer or POST /settings/email.
  2. Cookie-based (ambient) authentication for that action, with no other check that a cross-site page can't pass.
  3. A request the attacker can fully predict. Every parameter is known in advance: no secret token, no current password, no value only the real page would have.

Three ways to send a forged request

A page on another site can make the browser send requests to your app in more ways than you'd think. The three that matter:

  • A plain GET. img src="https://bank.example/transfer?to=attacker&amount=500", a link, or a redirect. If any GET endpoint changes state, it's the easiest target there is: it even works from an email or a forum post that allows images.
  • An auto-submitted form. A hidden form method="POST" with prefilled input type="hidden" fields, submitted by script on load. It can target the whole window (a top-level navigation, which the user sees) or a hidden iframe (which the user doesn't).
  • A fetch from script. fetch(url, { method: "POST", credentials: "include", mode: "no-cors", body }). The response is opaque and can't be read, but the request is sent.

Forms are the classic tool because they predate CORS and the browser has always allowed them across sites. A form can only send three body types: application/x-www-form-urlencoded, multipart/form-data, and text/plain. It can't add custom headers, and it can only use GET or POST.

The forged request, byte for byte

Here is what reaches the bank when Maya opens the recipe page, in a browser with no SameSite protection for this cookie. Look at what the server can and can't tell apart from a real transfer.

http
POST /transfer HTTP/1.1
Host: bank.example
Content-Type: application/x-www-form-urlencoded
Cookie: sid=7f3a9c21e0b4
Origin: https://recipes.example
Referer: https://recipes.example/
Sec-Fetch-Site: cross-site
Sec-Fetch-Mode: navigate
Sec-Fetch-Dest: document

to=attacker&amount=500
The cookie looks exactly like Maya's real one. Only the browser-set headers (Origin, Referer, Sec-Fetch-Site) reveal where the request came from, and only if the server reads them.

The session cookie is valid and the body is well formed. A server that only checks "is there a valid session?" moves the money.

But the request also carries headers the attacker's page can't control: the browser sets Origin and the Sec-Fetch-* headers itself, and page script can't override them. That fact powers the defenses in the chapter on checking where the request came from.

Why some requests never leave the browser

With fetch, the attacker can try more than a form can: a JSON body, a PUT, a custom header. But then the browser treats the request as non-simple and first sends a preflight: an OPTIONS request asking the server "will you accept a PUT with Content-Type: application/json from https://recipes.example?". Unless the server answers yes with the right CORS headers, the real request is never sent.

A request is simple (no preflight) only if its method is GET, HEAD, or POST, it sets no headers outside a short safe list, and its Content-Type, if any, is one of the three form types above. Everything else gets a preflight. So an API that only accepts application/json bodies, or only acts when a custom header is present, can't be reached by a forged request at all. The chapter on checking where the request came from looks at this closely; the CORS lesson covers preflight in full.

Try it in the lab below. Pick one of five attacks: three from the recipe page (a form, a text/plain fetch, a JSON fetch), a link to a GET endpoint, and a form on a hacked blog subdomain of the bank. Switch defenses on and off and watch which requests reach the bank with Maya's cookie, and which the bank accepts.

Attack
Session cookie SameSite
Bank defenses
Attack: hidden form POST, with SameSite=None. the browser sends it: yes. with Maya's cookie: yes. the bank's checks: all pass. result: 500 moved to attacker.the browser sends ityeswith Maya's cookieyesthe bank's checksall passresult500 moved to attacker
Still working with these settings: hidden form POST, fetch, text/plain, link to GET /transfer, form from blog subdomain.

Check your understanding · Exercise 3

An attacker's page runs fetch("https://bank.example/api/transfer", ...) with credentials: "include". Which versions are sent to the bank without a preflight? Pick one.

SameSite cookies: the browser's defense

The SameSite attribute on a cookie tells the browser when to leave it off. It's the cheapest CSRF defense there is: one attribute on the Set-Cookie header, no change to your pages. It has three values.

http
Set-Cookie: __Host-sid=7f3a9c21e0b4; Path=/; Secure; HttpOnly; SameSite=Lax
A good session cookie. SameSite=Lax handles most CSRF; HttpOnly hides it from script; Secure and the __Host- prefix stop subdomains and plain HTTP pages from overwriting it.
  • SameSite=Strict. Sent only on same-site requests. A cross-site request never carries it, not even when the user clicks a normal link to your site from an email or a search result.
  • SameSite=Lax. Sent on same-site requests, and on cross-site requests only when they are a top-level navigation with a safe method (GET or HEAD). So a link click carries it; a cross-site form POST, an iframe, an image, or a fetch don't.
  • SameSite=None. Sent on every request, same-site or cross-site, as before SameSite existed. Browsers require Secure with it, and many browsers block or partition such cookies when they are third-party anyway.
A matrix of whether the bank's session cookie is sent, for four kinds of request started by another page, under five cookie settings: Strict, Lax, None, no attribute in Chrome, and no attribute in Firefox and Safari. A link click (GET): Strict no, every other setting sent. A top-level form POST: Strict no, Lax no, None sent, unset in Chrome only within 2 minutes of the cookie being set, unset in other browsers sent. An iframe, image, or fetch: Strict no, Lax no, None sent unless third-party cookies are blocked, unset no in all browsers. A POST from a sibling subdomain: sent under every setting.StrictLaxNoneunsetChromeunsetotherslink click (GET)nosentsentsentsentform POST, top-levelnonosent2 minsentiframe, image, or fetchnonosent*nonosibling subdomain POSTsentsentsentsentsent* unless the browser blocks third-party cookies
Explicit Lax closes the form POST hole everywhere, but no setting helps with the last row: a sibling subdomain is the same site. The star means the browser may still block it as a third-party cookie.

Lax by default, and where it isn't

In 2020 Chrome began treating cookies with no SameSite attribute as Lax. The rollout started with Chrome 80 in February, paused that spring, resumed with Chrome 84 in July, and reached all users by August. Edge and the other Chromium browsers followed. Within months, most classic form-POST CSRF stopped working in those browsers against sites that had never heard of the attribute.

There is one hole on purpose. Some login flows (single sign-on that ends with a cross-site POST back to the app) broke under the new default. So Chrome lets a cookie that has no explicit SameSite attribute ride along on a top-level cross-site POST if the cookie is less than 2 minutes old. This is often called Lax+POST.

An attacker who can make the site issue a fresh cookie (for example, by sending the user through a login or a page that resets the session) gets a 2-minute window. Setting SameSite=Lax explicitly closes the window, because the exception only covers defaulted cookies.

The other engines took a different path. As of 2026, Firefox and Safari don't treat a cookie with no attribute as Lax: Firefox tried it and rolled it back over compatibility problems. Both instead block or partition third-party cookies (Safari's Intelligent Tracking Prevention blocks them; Firefox's Total Cookie Protection gives each top-level site its own cookie jar). That stops hidden iframe and fetch attacks, but a cross-site form POST that navigates the whole window is a first-party request, so the cookie goes along.

What Lax doesn't cover

  • GET endpoints that change state. Lax sends the cookie on cross-site top-level GETs. A GET /account/delete?confirm=1 link is still an attack. Keep GET safe: it reads, it never writes.
  • Same-site attackers. A compromised or taken-over sibling subdomain is same-site, so even Strict cookies go along.
  • Method override. Frameworks that read _method=DELETE from a query string or form field can turn a Lax-allowed GET or a form POST into a destructive call. Only honour overrides on POST, and still check tokens.
  • Old and unusual clients. Embedded web views, older browsers, and some in-app browsers may ignore the attribute.

Decision Which SameSite value for the session cookie?

Strict

SameSite=Strict on the session cookie

Every cross-site entry arrives without a session

Pros

  • Strongest browser-side block: no cross-site request ever carries it
  • Covers state-changing GETs too

Cons

  • Users who click a link to your app from email, Slack, or search land logged out until they navigate again
  • Breaks login flows that come back from another site

Lax

SameSite=Lax on the session cookie

Pair with safe GETs and a server-side check on writes

Pros

  • Links from other sites keep the user logged in
  • Blocks cross-site POST, iframes, images, and fetch

Cons

  • State-changing GETs stay exposed
  • No help against same-site attackers

Two cookies

A Lax cookie that is enough to read pages

A second Strict cookie required for any write

Pros

  • Logged-in links from other sites, plus Strict-level protection on writes
  • GitHub has described adding a Strict cookie like this next to its session

Cons

  • Two cookies to issue, rotate, and expire together
  • Slightly more server logic

What we pick: Default to Lax with explicit attributes, and add a server-side check (tokens or Fetch Metadata) for writes. Choose the two-cookie split for high-value apps where a forged write is very costly. Use plain Strict only for apps nobody reaches from links, like an admin console.

Check your understanding · Exercise 4

The session cookie is set with an explicit SameSite=Lax. Which cross-site attacks still carry it? Select all that apply.

CSRF tokens: a secret only your pages know

The classic server-side defense is the synchronizer token pattern. The server makes a random secret, stores it with the user's session, and puts it into every page it renders. Every state-changing request must send the secret back. The server compares the two and rejects the request if they don't match.

Why it works: the attacker's page can make the browser send a request, but it can't read your pages (the same-origin policy forbids it). So it can't learn the token, and its forged form has no valid value to put in the field.

A sequence diagram between Maya's browser, bank.example, and recipes.example. The browser gets the transfer page; the bank returns the form with token Xq9 and stores Xq9 in the session. The browser posts the form with token Xq9 and the bank answers 200 OK. Then recipes.example sends a forged form. The browser posts it to the bank with the cookie but no token, and the bank answers 403 Forbidden.Maya's browserbank.examplerecipes.exampleGET /transferform + token Xq9session: csrf = Xq9POST token=Xq9200 OKforged formPOST, no token403 Forbidden
The real form carries the token because the bank wrote it into the page. The forged form can't, because recipes.example can't read the bank's pages to learn it.

Step by step

  1. At login, the server creates a token from a secure random source, at least 128 bits (for example, 32 bytes from crypto.randomBytes, base64url-encoded), and stores it in the server-side session.
  2. When it renders a page with a form, it adds a hidden field: input type="hidden" name="csrf_token" value="Xq9...". For a single-page app it adds meta name="csrf-token" content="Xq9..." to the HTML shell, or returns it from an endpoint.
  3. The form posts the field with the rest of the data. A single-page app reads the meta tag once and sends the token in a header, like X-CSRF-Token, on every POST, PUT, PATCH, and DELETE.
  4. The server looks up the session from the cookie, compares the sent token with the stored one in constant time, and returns 403 Forbidden if they differ or the token is missing.
ts
import { timingSafeEqual } from "node:crypto";

const SAFE = new Set(["GET", "HEAD", "OPTIONS"]);

export function csrfCheck(req, res, next) {
  if (SAFE.has(req.method)) return next();
  const sent = req.get("x-csrf-token") ?? req.body?.csrf_token ?? "";
  const stored = req.session?.csrfToken ?? "";
  const a = Buffer.from(sent);
  const b = Buffer.from(stored);
  if (!stored || a.length !== b.length || !timingSafeEqual(a, b)) {
    return res.status(403).json({ error: "csrf_failed" });
  }
  next();
}
Synchronizer token check as Express-style middleware. Safe methods skip it, which is only correct if GET handlers never change state.

The choices inside the pattern

  • Per session or per request. One token per session is simple and works with many tabs and the Back button. A new token per request is a little stronger if a token leaks, but breaks when the user has two tabs open or goes back to an old form. Most frameworks use one per session.
  • Masking. Rails and Django send the token XOR-ed with a fresh random mask on every page, so the bytes in the HTML differ every time while the secret stays the same. This defeats BREACH, an attack that recovers secrets from compressed HTTPS responses by watching their size.
  • Never in the URL. A token in a query string leaks into logs, browser history, and the Referer header sent to other sites. Use a form field or a header.
  • Rotate at login. Issue a new token (and a new session ID) when the user logs in, so a token planted before login is useless after it.

The cost is state. The server must store the token with the session, which is natural for a classic server-rendered app with server sessions, and awkward for a stateless API that keeps no session store. That gap is what the next pattern fills.

Check your understanding · Exercise 5

Why can't the attacker's page simply fetch your form page first and copy the token out of it? Pick one.

Double-submit cookies, naive and signed

The double-submit cookie pattern drops the server-side store. The server puts a random token in a cookie that page script can read (not HttpOnly). The app's JavaScript reads the cookie and copies the value into a header or form field on each write. The server only checks that the cookie value and the header value match.

The idea: an attacker on another site can make the browser send the cookie, but can't read it, so they can't put the same value into the header. Angular's HttpClient works this way out of the box: it reads a cookie named XSRF-TOKEN and sends it back as X-XSRF-TOKEN.

Why the naive version is weak

The flaw is in the word "match". The server checks that two values are equal, but not that it created them. If the attacker can write a cookie into the user's browser for your domain, they choose the value, then send the same value in the forged form field. The two match, and the check passes.

Writing a cookie for your domain is easier than it sounds. Any sibling subdomain can set a cookie with Domain=bank.example, which the browser then sends to app.bank.example too (this is called cookie tossing). So can a plain-HTTP page on any of your hostnames, if an attacker on the same Wi-Fi can tamper with it. One forgotten subdomain undoes the defense.

Two panels side by side. In both, an attacker on promo.bank.example tosses a cookie csrf = AAA into the victim's browser, then a forged form sends csrf_token = AAA. Before, with naive double-submit, the server only checks that the cookie equals the field, so the request is accepted. After, with a signed token bound to the session, the server checks that the HMAC over the session ID and nonce is valid; the attacker can't compute it without the key, so the answer is 403 Forbidden.before: naive double-submittossed cookiecsrf = AAAforged formcsrf_token = AAAserver checkcookie equals field?match: acceptedafter: signed + session-boundtossed cookiecsrf = AAAforged formcsrf_token = AAAserver checkHMAC(sid, nonce) valid?no: 403 Forbidden
Same attack, same values. The naive check only asks whether two attacker-chosen values match; the signed check asks whether the server made this token for this session.

The fix: sign the token and bind it to the session

The signed double-submit cookie closes the hole. Instead of a bare random value, the token is a random value plus an HMAC (a keyed hash) over the session ID and that random value, using a secret key only the server has. On each write, the server checks two things: the header matches the cookie, and the HMAC is valid for this session.

ts
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";

const KEY = process.env.CSRF_HMAC_KEY!; // 32+ random bytes, server only

// Each part is prefixed with its length, so two parts can't be shifted into each other.
const message = (sessionId: string, nonce: string) =>
  `${sessionId.length}!${sessionId}!${nonce.length}!${nonce}`;

export function issueToken(sessionId: string): string {
  const nonce = randomBytes(16).toString("base64url");
  const mac = createHmac("sha256", KEY).update(message(sessionId, nonce)).digest("base64url");
  return `${mac}.${nonce}`; // set as cookie and read by the app
}

export function verifyToken(sessionId: string, cookie: string, header: string) {
  if (!cookie || cookie !== header) return false;
  const [mac, nonce = ""] = cookie.split(".");
  const expected = createHmac("sha256", KEY).update(message(sessionId, nonce)).digest();
  const got = Buffer.from(mac ?? "", "base64url");
  return got.length === expected.length && timingSafeEqual(got, expected);
}
The message layout follows the OWASP cheat sheet. An attacker who tosses a cookie can't produce a valid HMAC for the victim's session, because they don't have the key.

The attacker can still toss a cookie, but they can't compute a valid HMAC for Maya's session ID without the server's key. The server stays stateless: it needs the key and the session ID, which it already reads from the session cookie or JWT.

  • Use the __Host- prefix on the session cookie (and the CSRF cookie where you can). A cookie named __Host-... must be Secure, have Path=/, and have no Domain, so a subdomain can't set or overwrite it.
  • Bind to the session. A token that only proves "the server issued this" can be swapped: the attacker logs in to their own account, gets a valid token, and tosses it into the victim's browser. Including the session ID in the HMAC means a token only works with the session it was issued for.
  • Rotate with the session. A new session ID at login invalidates every old token automatically.
The three token patterns compared.
QuestionSynchronizer tokenNaive double-submitSigned double-submit
Server stateToken stored in sessionNoneNone (needs a secret key)
Survives cookie tossing from a subdomainYesNoYes
Works with a stateless APIAwkwardYesYes
Cookie readable by scriptNo cookie neededYesYes (the CSRF cookie only)
Main mistakeForgetting a routeTrusting any matching pairNot binding to the session

Check your understanding · Exercise 6

An attacker controls promo.bank.example and can set cookies for .bank.example. Which defenses still stop their forged POST to app.bank.example? Select all that apply.

Checking where the request came from

Tokens make the page prove it's yours. A lighter approach asks the browser instead. Modern browsers attach headers that describe where a request came from, and page script can't set or change them. The server reads them and rejects writes that came from another site.

The Origin header

Browsers send Origin on every CORS request (a fetch to another origin) and on every POST, PUT, PATCH, or DELETE, cross-origin or not, including plain form posts. A forged form POST from the recipe blog arrives with Origin: https://recipes.example. The check is a short allow-list: if the method is unsafe and Origin isn't one of your own origins, reject.

  • Compare whole origins, exactly. https://bank.example against a set of strings. Don't use endsWith("bank.example"), which evilbank.example passes, or a regex without anchors.
  • Treat Origin: null as foreign. Sandboxed iframes, file: pages, some redirects, and a Referrer-Policy: no-referrer page can all send null. An attacker can produce it on purpose, so never allow-list it.
  • Fall back to Referer with care. If Origin is missing (very old browsers), the Referer header's origin is a weaker substitute. Privacy settings often strip it, so decide whether a missing header means reject (safer) or allow (more compatible).

Fetch Metadata: Sec-Fetch-Site

The Fetch Metadata headers are newer and richer. Every request to an HTTPS URL carries Sec-Fetch-Site, with one of four values: same-origin, same-site, cross-site, or none (the user typed the URL, used a bookmark, or the browser started it). Sec-Fetch-Mode says how it was made (navigate, cors, no-cors), and Sec-Fetch-Dest says what it's for (document, image, script, empty for fetch).

Chrome has sent them since 2019, Firefox since 2021, and Safari since version 16.4 in 2023, so every current engine does. Headers starting with Sec- are forbidden to page script, so an attacker can't fake them. Google's resource isolation policy turns them into a CSRF defense in a few lines:

A flowchart of the resource isolation policy. First: is Sec-Fetch-Site sent? If no, it's an old browser, so check Origin instead. If yes: is it same-origin or none? If yes, allow: your own pages. If no: is it a GET navigation? If yes, allow: a link to a page. If no: is the path opted out? If yes, allow: a public cross-site API. If no, reject with 403: a cross-site or same-site request.Sec-Fetch-Site sent?noold browsercheck Origin insteadyessame-origin or none?yesallowyour own pagesnoGET navigation?yesallowa link to a pagenopath opted out?yesallowa public cross-site APIno403: reject
Three of the four questions read headers the attacker cannot set; the fourth is your own list of public paths. Note that same-site falls through to the bottom on purpose: a sibling subdomain does not get to write.
ts
export function isolationPolicy(req, res, next) {
  const site = req.get("sec-fetch-site");
  // Older browser without Fetch Metadata: let other checks decide.
  if (!site) return next();
  if (site === "same-origin" || site === "none") return next();
  // Plain cross-site links and reloads: GET navigations to documents.
  const mode = req.get("sec-fetch-mode");
  const dest = req.get("sec-fetch-dest");
  if (mode === "navigate" && req.method === "GET" && dest !== "object" && dest !== "embed") {
    return next();
  }
  // Endpoints meant to be called by other sites opt out by path.
  if (PUBLIC_CROSS_SITE_PATHS.has(req.path)) return next();
  return res.status(403).send("cross-site request blocked");
}
A resource isolation policy, adapted from web.dev's version, which also allows same-site. It also blocks cross-site image, script, and fetch loads of your API, which stops some data-leak attacks too.

Notice that same-site is rejected here, unlike in web.dev's sample. That's on purpose: a compromised sibling subdomain is same-site, and the app rarely needs requests from it. If you do (say www posts to api), add those exact origins as an exception instead of trusting the whole site.

Why JSON APIs with custom headers resist CSRF

Remember the simple-request rule from earlier in this lesson. A cross-site page can only send a request without a preflight if it looks like something a form could send. An API that requires either Content-Type: application/json or a custom header like X-Requested-With forces every cross-site caller into a preflight, and the preflight fails unless your CORS policy says yes to that origin. The forged request never leaves the browser.

Method
Content-Type
Extras
A POST request with application/json, sent from recipes.example to a JSON API. a plain HTML form can send it: no. fetch needs a preflight: yes: OPTIONS first. reaches the API: no: CORS says no. the API runs it as a write: never sees it.a plain HTML form can send itnofetch needs a preflightyes: OPTIONS firstreaches the APIno: CORS says nothe API runs it as a writenever sees it
This request needs a preflight, so the attacker's page can't send it unless your CORS policy lets their origin in.

This is why many single-page apps are protected without knowing it. But the protection is only as strong as the server's strictness, and three mistakes undo it:

  • A lenient body parser. If the server parses the body as JSON whatever the Content-Type says, an attacker sends text/plain with a JSON body as a simple request. Reject any write whose Content-Type isn't exactly application/json.
  • The header is optional. The server must reject requests that lack the custom header, not just behave the same with or without it.
  • A loose CORS policy. If the server reflects any Origin into Access-Control-Allow-Origin with Access-Control-Allow-Credentials: true, the preflight passes for every site and the protection is gone. See the CORS lesson.

Check your understanding · Exercise 7

Which of these server checks would reject Maya's forged form POST from https://recipes.example? Select all that apply.

Edge cases and failure modes

Most real CSRF bugs aren't in the main defense. They're in the corners: a route that skipped the check, a flow nobody thought was sensitive, or another hole that makes the defense pointless. Here are the ones that show up in bug bounty reports again and again.

Login CSRF

Login CSRF flips the attack around. The attacker forges a request to your login form with their own username and password. The victim's browser ends up logged in to the attacker's account without noticing. Nothing is stolen at that moment; the harm comes after.

The victim then searches, saves a card, or uploads documents, all into the attacker's account, where the attacker can later read them. Search engines, shops, and cloud drives are classic targets. Because the login form runs before there is a session, teams often leave it out of their CSRF check.

  • Protect the login endpoint too: check Origin or Sec-Fetch-Site, or use a pre-session token tied to a short-lived cookie.
  • Issue a new session ID and a new CSRF token on every login, so nothing set before login survives it.
  • For OAuth and OpenID Connect logins, the callback needs its own CSRF defense: the state parameter, or PKCE (which current OAuth security guidance accepts in its place). Never skip it.

Other corners

  • XSS beats everything. Script running on your origin reads the token, sets custom headers, and sends same-origin requests that pass every check. CSRF defenses assume no XSS; pair them with the XSS and CSP lessons.
  • Logout and other "harmless" actions. Forced logout can be chained with login CSRF, and "add to cart" or "follow" can be abused at scale. Protect every write, then exempt by name.
  • WebSockets. The handshake is a GET that carries cookies and isn't subject to CORS. Cross-site WebSocket hijacking opens a socket as the user and, unlike classic CSRF, can read the replies. Check the Origin header on the upgrade request.
  • Cached pages with tokens. A CDN or shared cache that stores an HTML page containing one user's CSRF token can serve it to others. Mark such pages Cache-Control: private, no-store, or fetch the token separately.
  • Expired tokens. A form left open overnight fails with 403 when the session rotates. Show a clear message, keep what the user typed, and fetch a fresh token instead of a blank error page.
  • Subdomain takeover. A DNS record pointing at a deleted cloud resource lets an attacker host pages on your site, which defeats SameSite and naive double-submit at once. Audit DNS regularly.
A stack of CSRF defenses. In the browser: a SameSite=Lax cookie, which misses state-changing GET links and sibling subdomains; and the preflight for JSON and custom headers, which misses lenient body parsers and a CORS policy that reflects any origin. On the server: a Sec-Fetch-Site or exact Origin check, which misses very old browsers; and a token on every write, which misses routes added outside the middleware. Under everything: no XSS, kept by CSP and safe rendering. If that falls, every layer above falls with it.in the browseron the serverSameSite=Lax cookiemisses: state-changing GET links, sibling subdomainspreflight for JSON and custom headersmisses: lenient body parsers, a CORS policy that reflects any originSec-Fetch-Site or exact Origin checkmisses: very old browsers without the headerstoken on every writemisses: routes added outside the middlewareno XSS: CSP and safe renderingif this falls, every layer above falls with it
Each layer has a known gap, and the gaps don't line up, so an attack has to get through all of them. The bottom layer is different: XSS runs inside your origin and passes every check above.

The stack above is the point of this chapter. No single layer covers every corner: SameSite misses GETs and siblings, Fetch Metadata misses very old browsers, tokens miss forgotten routes, and none of them survives XSS. Together they leave very little.

Check your understanding · Exercise 8

In a login CSRF attack, what does the attacker gain? Select all that apply.

In real frameworks and products

You rarely write CSRF protection from scratch. Most server frameworks ship it switched on, and the job is knowing what yours does, what it leaves out, and how your frontend has to cooperate with it.

What popular frameworks do out of the box. Check your version's docs: defaults change.
FrameworkDefault defenseWhat the frontend does
DjangoCsrfViewMiddleware on by default: a secret in the csrftoken cookie and a masked token in each form, plus an Origin check (and a strict Referer check on HTTPS when Origin is missing)Forms use the csrf_token template tag; fetch sends the cookie value as X-CSRFToken
Ruby on Railsprotect_from_forgery in new apps: a masked synchronizer token per session, plus an Origin checkcsrf_meta_tags puts the token in the page; Rails UJS and Turbo send it as X-CSRF-Token
LaravelCSRF middleware on web routes; the token also goes out in an encrypted XSRF-TOKEN cookieBlade's @csrf adds the form field; Axios sends X-XSRF-TOKEN from the cookie on its own
Spring SecurityOn by default for unsafe methods, with BREACH masking since version 6Use CookieCsrfTokenRepository for SPAs and send the header
AngularHttpClient reads the XSRF-TOKEN cookie and sends X-XSRF-TOKEN on writes to relative URLsNothing, if the server sets the cookie and checks the header
Next.js Server ActionsPOST only, and the Origin must match the Host (or X-Forwarded-Host)Nothing for actions; plain Route Handlers get no automatic check
SvelteKitAn Origin check on form-type submissions: those from other origins get 403 (on by default via csrf.checkOrigin in SvelteKit 2; always on in SvelteKit 3, with csrf.trustedOrigins for exceptions)Nothing; JSON endpoints rely on the preflight
Go 1.25+Opt-in http.CrossOriginProtection middleware rejects unsafe cross-origin requests using Sec-Fetch-Site, falling back to comparing Origin with HostNothing; the server wraps its handlers with it

Patterns worth copying

  • GitHub has described adding a second session cookie next to its normal one, with SameSite=Strict and the __Host- prefix, as an extra layer on top of its form tokens. A request that arrives without the Strict cookie came from another site, so a server using this split can treat it as a read-only visit: links from other sites still show the user logged in, while writes need the Strict cookie as well.
  • Backend for frontend with a token handler. When the browser only holds an HttpOnly SameSite session cookie for the BFF and the BFF holds the real access tokens, the BFF is the one place to enforce CSRF. It checks Sec-Fetch-Site and a custom header on every write. See the BFF lesson.
  • Bearer tokens in headers. Apps that send Authorization: Bearer ... from JavaScript and use no cookies for auth have no ambient credential, so CSRF doesn't apply. They trade it for a larger XSS risk, since script can read the token; the auth tokens lesson weighs that trade.
  • Home routers and intranet tools. Old router admin pages accepted GET requests to change DNS settings, so a single image on any website could reroute a home network. This is why browsers are starting to restrict public websites from sending requests to private and local network addresses.

The frontend's part of the deal

Even when the server does the checking, the frontend has to cooperate. A small API client wrapper is the right place, so no call site can forget:

ts
const token = document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") ?? "";

export async function api(path: string, init: RequestInit = {}) {
  const method = (init.method ?? "GET").toUpperCase();
  const headers = new Headers(init.headers);
  if (method !== "GET" && method !== "HEAD") {
    headers.set("X-CSRF-Token", token);
    if (!headers.has("Content-Type")) headers.set("Content-Type", "application/json");
  }
  const res = await fetch(path, { ...init, method, headers, credentials: "same-origin" });
  if (res.status === 403) {
    // Token expired or session rotated: refresh it and ask the user to retry.
    throw new Error("csrf_failed");
  }
  return res;
}
One wrapper for every call: the token header, the JSON content type, and same-origin credentials, all in one place.

Check your understanding · Exercise 9

Your Next.js app exposes a Route Handler at POST /api/settings that reads a session cookie. What protects it from CSRF? Select all that apply.

Using it in a system design interview

CSRF comes up in two ways. Sometimes the interviewer asks directly ("how do you protect this form?"). More often it hides inside a bigger question: you chose cookie sessions for a dashboard, a bank, or a chat app, and a good interviewer will ask what stops another site from using that cookie. Bring it up yourself the moment you choose cookies for auth.

What to draw

Don't draw the attack in detail; one sentence covers it. Draw where each check lives in your architecture, because that shows you know which layer owns what.

An architecture in three zones. In the browser: the SPA, whose api wrapper adds X-CSRF-Token, and the cookie jar holding __Host-sid with SameSite=Lax. Requests go to the BFF or API gateway, which runs three checks: Sec-Fetch-Site allows same-origin writes only, JSON plus token on every write, and CORS with an exact allow-list. Behind it: the sessions store and the accounts service, which trusts the BFF.browserSPAapi() adds headercookie jar__Host-sid, LaxBFF or gatewaySec-Fetch-Sitesame-origin onlyJSON + tokenon every writeCORSexact allow-listservicessessionsaccountstrusts the BFFevery write passes all three checks before it reaches a service
Draw the checks in one place, the first server the browser talks to. The services behind it don't see browser cookies, so they don't need their own CSRF logic.

What to say, in about a minute

  1. Name the risk. "Sessions are in an HttpOnly cookie, so any site can make the browser send it. That's CSRF."
  2. Browser layer. "The cookie is __Host-, Secure, SameSite=Lax, set explicitly, so cross-site POSTs, iframes, and fetches don't carry it."
  3. Server layer. "Every unsafe request is checked: Sec-Fetch-Site must be same-origin, falling back to an exact Origin match. The API only accepts application/json, so a cross-site write needs a preflight our CORS policy refuses."
  4. Corners. "GETs never change state. Login is protected too, and the session rotates at login. WebSocket upgrades check Origin."
  5. Limits. "None of this survives XSS, so we also have a strict CSP."

How deep to go by level

LevelWhat a strong answer shows
JuniorExplains that cookies are sent automatically, that the attacker can send but not read, and that SameSite cookies and CSRF tokens are the standard fixes
MidKnows Lax vs Strict and why GET must be safe, puts the token in a header from one API wrapper, and knows JSON plus a custom header forces a preflight
SeniorSeparates same-site from same-origin, explains cookie tossing and signed double-submit, uses Fetch Metadata, covers login CSRF and WebSockets, and states what the design does not protect against

Check your understanding · Exercise 10

You're designing a banking web app with cookie sessions. Which statements belong in a senior-level answer? Select all that apply.

Summary

  • CSRF works because cookies are ambient: the browser attaches them to requests to your site whichever page started the request. The attacker can send, but can't read the response.
  • Same-site is wider than same-origin: sibling subdomains are same-site, so SameSite cookies don't protect you from your own subdomains.
  • Set SameSite=Lax (or Strict) explicitly with Secure, HttpOnly, and the __Host- prefix. Don't rely on Chrome's default, and remember Firefox and Safari don't apply it.
  • GET must never change state: Lax cookies go along with cross-site links.
  • On the server, check writes with Sec-Fetch-Site (falling back to an exact Origin match), or use a synchronizer token or a signed, session-bound double-submit cookie. Naive double-submit falls to cookie tossing.
  • JSON APIs that require application/json or a custom header force a preflight, which blocks forged requests, as long as the parser is strict and CORS doesn't reflect any origin.
  • Protect login, OAuth callbacks, and WebSocket upgrades too. And none of these defenses survives XSS.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

Explain CSRF to me, and why the attacker can't just read the response.

Question 2

Chrome makes cookies SameSite=Lax by default. Isn't CSRF solved?

Question 3

What's wrong with the naive double-submit cookie, and how do you fix it?

Question 4

Our SPA talks to a JSON API with a session cookie. Do we need CSRF tokens?

Question 5

What is login CSRF and how do you stop it?

Practice it

These design problems lean on cross-site request forgery (csrf).

References

Read next