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.
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.1trusts anyone who can reach it). - Not ambient: a token kept in memory or in
localStorageand added to eachfetchby 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:
- Make the courier smarter. Tell the browser not to hand over the key for parcels from strangers. That's the
SameSitecookie attribute: the browser leaves the cookie off cross-site requests. - 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
OriginandSec-Fetch-Siteheaders 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.
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.
- A state-changing action the target app performs on a single request, like
POST /transferorPOST /settings/email. - Cookie-based (ambient) authentication for that action, with no other check that a cross-site page can't pass.
- 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 prefilledinput type="hidden"fields, submitted by script on load. It can target the whole window (a top-level navigation, which the user sees) or a hiddeniframe(which the user doesn't). - A
fetchfrom 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.
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
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.
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.
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.
Step by step
- 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. - 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 addsmeta name="csrf-token" content="Xq9..."to the HTML shell, or returns it from an endpoint. - 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. - The server looks up the session from the cookie, compares the sent token with the stored one in constant time, and returns
403 Forbiddenif they differ or the token is missing.
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();
}
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
Refererheader 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.
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.
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 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 beSecure, havePath=/, and have noDomain, 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.
| Question | Synchronizer token | Naive double-submit | Signed double-submit |
|---|---|---|---|
| Server state | Token stored in session | None | None (needs a secret key) |
| Survives cookie tossing from a subdomain | Yes | No | Yes |
| Works with a stateless API | Awkward | Yes | Yes |
| Cookie readable by script | No cookie needed | Yes | Yes (the CSRF cookie only) |
| Main mistake | Forgetting a route | Trusting any matching pair | Not 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.exampleagainst a set of strings. Don't useendsWith("bank.example"), whichevilbank.examplepasses, or a regex without anchors. - Treat
Origin: nullas foreign. Sandboxed iframes,file:pages, some redirects, and aReferrer-Policy: no-referrerpage can all sendnull. An attacker can produce it on purpose, so never allow-list it. - Fall back to
Refererwith care. IfOriginis missing (very old browsers), theRefererheader'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:
same-site falls through to the bottom on purpose: a sibling subdomain does not get to write.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");
}
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.
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-Typesays, an attacker sendstext/plainwith a JSON body as a simple request. Reject any write whoseContent-Typeisn't exactlyapplication/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
OriginintoAccess-Control-Allow-OriginwithAccess-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
OriginorSec-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
stateparameter, 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
Originheader 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
403when 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
SameSiteand naive double-submit at once. Audit DNS regularly.
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.
| Framework | Default defense | What the frontend does |
|---|---|---|
| Django | CsrfViewMiddleware 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 Rails | protect_from_forgery in new apps: a masked synchronizer token per session, plus an Origin check | csrf_meta_tags puts the token in the page; Rails UJS and Turbo send it as X-CSRF-Token |
| Laravel | CSRF middleware on web routes; the token also goes out in an encrypted XSRF-TOKEN cookie | Blade's @csrf adds the form field; Axios sends X-XSRF-TOKEN from the cookie on its own |
| Spring Security | On by default for unsafe methods, with BREACH masking since version 6 | Use CookieCsrfTokenRepository for SPAs and send the header |
| Angular | HttpClient reads the XSRF-TOKEN cookie and sends X-XSRF-TOKEN on writes to relative URLs | Nothing, if the server sets the cookie and checks the header |
| Next.js Server Actions | POST only, and the Origin must match the Host (or X-Forwarded-Host) | Nothing for actions; plain Route Handlers get no automatic check |
| SvelteKit | An 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 Host | Nothing; 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=Strictand 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
HttpOnlySameSitesession cookie for the BFF and the BFF holds the real access tokens, the BFF is the one place to enforce CSRF. It checksSec-Fetch-Siteand 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:
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;
}
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.
What to say, in about a minute
- Name the risk. "Sessions are in an
HttpOnlycookie, so any site can make the browser send it. That's CSRF." - Browser layer. "The cookie is
__Host-,Secure,SameSite=Lax, set explicitly, so cross-site POSTs, iframes, and fetches don't carry it." - Server layer. "Every unsafe request is checked:
Sec-Fetch-Sitemust besame-origin, falling back to an exactOriginmatch. The API only acceptsapplication/json, so a cross-site write needs a preflight our CORS policy refuses." - Corners. "GETs never change state. Login is protected too, and the session rotates at login. WebSocket upgrades check
Origin." - Limits. "None of this survives XSS, so we also have a strict CSP."
How deep to go by level
| Level | What a strong answer shows |
|---|---|
| Junior | Explains that cookies are sent automatically, that the attacker can send but not read, and that SameSite cookies and CSRF tokens are the standard fixes |
| Mid | Knows 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 |
| Senior | Separates 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
SameSitecookies don't protect you from your own subdomains. - Set
SameSite=Lax(orStrict) explicitly withSecure,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 exactOriginmatch), 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/jsonor 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.
CSRF is when a page on another site makes the user's browser send a request to my app. The browser attaches my app's cookies on its own, so my server sees a valid session and does what the request asks: a transfer, an email change, a delete.
The attacker can send but not read, because the same-origin policy keeps a cross-origin response away from their script. So CSRF is a blind write attack, and every defense works by requiring something in the request that only my own pages can produce, or by keeping the cookie off cross-site requests.
Question 2
Chrome makes cookies SameSite=Lax by default. Isn't CSRF solved?
Not quite. The default only applies to cookies without an explicit attribute, Chrome lets such cookies ride on a top-level cross-site POST for 2 minutes after they're set, and Firefox and Safari don't apply Lax by default at all; they block or partition third-party cookies, which doesn't stop a top-level form POST.
Even an explicit Lax cookie is sent on cross-site top-level GETs, so any state-changing GET is exposed, and SameSite does nothing against a same-site attacker such as a compromised subdomain. So I set SameSite explicitly and still check writes on the server with Fetch Metadata or Origin, or a token.
Question 3
What's wrong with the naive double-submit cookie, and how do you fix it?
It only checks that the cookie and the header match, not that the server created the value. Anyone who can write a cookie for my domain (a sibling subdomain, or a plain-HTTP page on the same host) picks a value, sets it as the cookie, and sends the same value in the form.
The fix is the signed double-submit: the token is a random value plus an HMAC over the session ID and that value, with a server-only key. The server checks the match and the signature for the current session. I'd also use the __Host- prefix on the session cookie so subdomains can't overwrite it.
Question 4
Our SPA talks to a JSON API with a session cookie. Do we need CSRF tokens?
Maybe not, if the API is strict. A cross-site page can only send simple requests without a preflight, and those can't use application/json or custom headers. If every write requires Content-Type: application/json exactly, and our CORS allow-list is short and exact, the forged request is stopped at the preflight.
I'd still add a Sec-Fetch-Site check (falling back to Origin) because it's one line and covers a lenient parser or a CORS mistake, and I'd keep every GET free of side effects. Tokens become worth it if we must support clients without these headers or accept form posts.
Question 5
What is login CSRF and how do you stop it?
The attacker forges a login with their own credentials, so the victim's browser ends up in the attacker's account. Whatever the victim then saves or searches lands where the attacker can read it.
I protect the login endpoint like any other write, with an Origin or Sec-Fetch-Site check or a pre-session token, rotate the session ID at login, and for OAuth logins always verify the state parameter on the callback.
Practice it
These design problems lean on cross-site request forgery (csrf).
References
- Cross-Site Request Forgery Prevention Cheat Sheet OWASP's full list of defenses, including signed double-submit.
- Set-Cookie: SameSite MDN on Strict, Lax, and None.
- SameSite cookies explained web.dev on the Lax default and what same-site means.
- Protect your resources from web attacks with Fetch Metadata The resource isolation policy, with sample code.
- Sec-Fetch-Site MDN on the header's values and browser support.
- Cross-Origin Resource Sharing (CORS) Simple requests and preflights, in full.
- net/http: CrossOriginProtection Go's built-in Fetch Metadata and Origin check, added in Go 1.25.
Read next
- Cross-site scripting (XSS)Cross-site scripting is when text from outside your app ends up running as code inside your page, with every power your own code has: the most common and most damaging frontend security bug, and the one frontend engineers own most directly.
- Content Security PolicyA response header that tells the browser which scripts, frames, and connections a page may use, so an injected script is blocked even when an XSS bug slips through.
- Backend for frontend (BFF)A small server, usually owned by the frontend team, that gathers data from many backend services and returns exactly what one screen of one kind of client needs.
- Client storage optionsThe six places a web app can keep data on the user's device (cookies, localStorage, sessionStorage, IndexedDB, the Cache API, and OPFS), how big, how fast, and how long-lived each one is, and how to pick the right one for each kind of data.
- CORSThe rules that let a page read responses from another origin, and the headers a server sends to allow it: a browser feature that relaxes the same-origin policy to protect users' data, not a wall that protects your server.
- Authentication and token storageHow a web app proves who the user is on every request after login: sessions or tokens, where the proof lives in the browser, how it is refreshed and revoked, and how OAuth with PKCE and OIDC fit in.