CSRF — Cross-Site Request Forgery
A Complete 10-Chapter Security Course
Table of Contents
- What CSRF Is & Why It Works
- How the Browser Enables It
- Anatomy of an Attack
- CSRF vs XSS vs SSRF
- The Synchronizer Token Pattern
- Double-Submit Cookie & Stateless Tokens
- SameSite Cookies
- Defence in Depth
- CSRF in Modern Apps
- Testing, Pitfalls & Checklist
What CSRF Is & Why It Works
Cross-Site Request Forgery (CSRF) tricks a logged-in user's browser into making a request the user never intended β to a site they're already authenticated with. The attacker can't read the response or steal the session; they simply cause an action to happen using the victim's existing authority. This chapter explains the mechanism that makes that possible, because every defence later in the course targets one specific link in this chain.
The Core Idea: A Confused Deputy
CSRF is a classic confused deputy problem. A "deputy" is something that acts on behalf of someone with more authority than the requester. Here the deputy is your browser: it holds your authority for yourbank.com (your session), and it will faithfully attach that authority to any request to that site β even a request initiated by a totally unrelated, malicious page. The browser is "confused" into using your authority for the attacker's intent.
Why the Browser Cooperates: Ambient Authority
The enabling mechanism is ambient authority β authority that's applied automatically, in the background, without the requesting code having to present it explicitly. Cookies are the textbook example:
| The browser rule | The consequence for CSRF |
|---|---|
| Cookies for a domain are attached to every request to that domainβ¦ | β¦even when the request was triggered by a different site |
| This happens automatically, with no action from the page making the request | The attacker's page doesn't need (or get) access to the cookie itself |
| The server sees a request with a valid session cookie | It looks indistinguishable from a genuine one |
That last row is the crux: from the server's perspective, a forged request carrying your valid session cookie looks exactly like a legitimate one. There's nothing in the cookie alone to reveal whether you intended the request or an attacker's page did. That ambiguity is the vulnerability.
A Concrete Attack: The Forged Money Transfer
Suppose yourbank.com performs transfers via a simple form POST:
Now the attacker hosts this on evil.com and lures you to visit it (a link, an ad, an email):
You never clicked "transfer." You may never even see it happen β the attacker doesn't need to read the response, only to trigger the action. That's a complete CSRF attack.
What CSRF Is Not
Precision here prevents confusion later (Chapter 4 expands this):
- Not credential theft β the attacker never obtains your password or cookie; they ride the session you already have.
- Not data theft β classic CSRF is "write-only." The attacker causes an action but generally can't read the response (the same-origin policy blocks that). CSRF is about doing, not reading.
- Not XSS β XSS runs the attacker's script on the victim site; CSRF runs no script on the target at all, it just sends a request to it from elsewhere. Different attack, different defence.
Hands-On Exercises
In your own words, explain the "confused deputy" framing of CSRF: who is the deputy, whose authority does it hold, and how is it confused? Then state precisely why the attacker does not need to steal the session cookie for the attack to work.
π View solutionWrite out the five steps of the forged-transfer attack as a sequence, labelling at which step the victim's cookie gets attached and by whom. Then identify which single step would be prevented if the bank required a secret value the attacker couldn't predict.
π View solutionList the three preconditions a CSRF attack requires. For each, give a one-line example of a defence (from anywhere you know, or guess) that would remove it. This builds the mental map the rest of the course fills in.
π View solutionChapter 1 Quick Reference
- CSRF = tricking a logged-in user's browser into making an unintended request to a site they're authenticated with
- Confused deputy β the browser is the deputy; it applies your authority to a request an attacker initiated
- Ambient authority β cookies auto-attach to every request to their domain, even cross-site ones
- A forged request with your valid cookie looks identical to a genuine one to the server β that ambiguity is the vulnerability
- CSRF attacks intent, not authentication β no password or cookie is stolen
- Classic CSRF is write-only β it causes an action; it generally can't read the response (same-origin policy)
- Three preconditions: cookie-only authorization Β· forgeable parameters Β· cross-site credentialed requests β break any one to stop it
- Next chapter: how the browser enables it β cookies, credentialed requests, and which elements send them cross-site
How the Browser Enables It
Chapter 1 named the mechanism β ambient authority. This chapter looks at the browser machinery in detail: how cookies get attached, what "credentialed request" really means, and crucially which ways of making a request will carry the victim's cookies cross-site. Knowing exactly which elements are dangerous (and which aren't) is what lets you reason about both attacks and defences precisely.
How Cookies Get Attached β by Destination, Not Origin
The single rule behind all of CSRF: the browser attaches a cookie based on where the request is going, not where it came from. When a request heads to yourbank.com, the browser looks up the cookies stored for yourbank.com and includes them β and it does not care which page initiated that request. A script on evil.com triggering a request to the bank gets the bank's cookies attached just the same.
SameSite cookie attribute (Chapter 7) exists precisely to let a cookie say "don't attach me on cross-site requests," but to understand why that's a fix you first have to see the default behaviour it changes.
"Credentialed" Requests
A credentialed request is one the browser sends with the user's ambient credentials (cookies, HTTP auth, client certs) attached. Whether a request is credentialed depends on how it's made:
- Navigations & form submissions β always credentialed. A
<form>POST or following a link always carries cookies for the destination. This is the CSRF workhorse. - Resource loads (
<img>,<script>,<iframe>) β credentialed. They fetch their target with cookies attached, which is why a GET that changes state is forgeable via an image tag. - fetch / XMLHttpRequest β credentialed only if asked. By default cross-origin
fetchsends no cookies; you must opt in withcredentials: 'include'β and even then the response is gated by CORS.
The Critical Asymmetry: Sending vs Reading
Here's the distinction that explains why classic CSRF is "write-only," and it trips up almost everyone at first:
| Action | Allowed cross-site by default? | Governed by |
|---|---|---|
| Browser sends a credentialed request (form/img/script) | YES β always has been | cookie attach rules (the CSRF gap) |
| Attacker page reads the cross-site response | NO β blocked | Same-Origin Policy / CORS |
The Same-Origin Policy (SOP) stops one origin from reading another origin's responses β so the attacker's page can't see your bank balance. But SOP was never designed to stop the browser from sending a cross-site request in the first place. CSRF lives entirely in that gap: sending is allowed even though reading is forbidden. The attacker doesn't need to read; causing the action is the whole goal.
The Role of HTTP Methods β and "Simple Requests"
Methods matter because of how easily each can be forged without any special permission:
| Forgeable via⦠| Methods / content types | Needs CORS preflight? |
|---|---|---|
| plain HTML (img, form) | GET; POST with form content types | No β fires freely |
| only via fetch/XHR | PUT, DELETE, PATCH, or JSON content type | Yes β preflighted |
A <form> can only send GET or POST, and only with three "simple" content types (application/x-www-form-urlencoded, multipart/form-data, text/plain). These are exactly the requests a browser will send cross-site without a CORS preflight (the automatic OPTIONS check). Anything beyond that β a DELETE, or a Content-Type: application/json β triggers a preflight the attacker's forged request can't satisfy, so it's effectively blocked.
application/json, a classic HTML-form CSRF attack can't even produce a valid request β a cross-site fetch with a JSON content type triggers a preflight, and your server can decline it. Likewise, requiring a custom header (which forms can't set) forces a preflight. This is why JSON APIs with custom headers are harder to CSRF β though, as the myth box warns, "harder" is not "immune," and you should still use explicit defences.
Two Methods, Two Honest Misuses
A recurring root cause: state-changing actions exposed over GET. Because <img>, <script>, and links all issue credentialed GETs silently, an endpoint like GET /account/delete?id=5 can be fired by merely loading an image tag on any page β no form, no script even needed. The HTTP spec says GET must be safe (no side effects) for exactly this reason. POST is not magically secure either, but at least it can't be triggered by a bare <img>.
Hands-On Exercises
Open DevTools β Network on a site you're logged into, trigger a request, and find the Cookie request header. Then articulate the rule that decided those cookies were attached β is it based on the page you're on, or the request's destination? Why does that rule enable CSRF?
Explain why CORS does not protect against CSRF. Be precise about what CORS actually governs (reading vs sending) and walk through why a forged POST still executes on the server even though the attacker's JavaScript can't read the response.
π View solutionClassify each as easily-forgeable-cross-site or not, and say why: (a) GET /search?q=x via an img tag; (b) a form POST with application/x-www-form-urlencoded; (c) a fetch with DELETE; (d) a fetch sending Content-Type: application/json. Reference preflight where relevant.
Chapter 2 Quick Reference
- Cookies attach by destination, not origin β a request to yourbank.com gets the bank's cookies whoever triggered it
- Same-site (page & destination match) vs cross-site (differ) β historically cookies sent on both
- Credentialed request = sent with the user's cookies; forms/navigations & resource loads always are,
fetchonly withcredentials:'include' - Critical asymmetry: the browser sends cross-site requests freely, but the Same-Origin Policy blocks reading the response β CSRF lives in that gap (write-only)
- CORS is NOT a CSRF defence β it governs reading responses, not whether the request is sent/processed
- HTML forms send only GET/POST with "simple" content types β no preflight β freely forgeable; JSON/DELETE/custom headers trigger a preflight
- State-changing GET is the worst offender β forgeable by a bare
<img>; GET must be safe (no side effects) - Next chapter: anatomy of an attack β crafting the malicious page, GET vs POST forgery, real-world patterns
Anatomy of an Attack
With the mechanism understood (Chapters 1β2), this chapter is the attacker's toolkit β assembled here so you can recognize and test for these patterns defensively, which is the whole point of studying them. We'll build each forgery type from simplest to most capable, note what each requires of the target endpoint, and look at how attacks are delivered.
Level 1: GET Forgery with an Image Tag
The simplest possible attack needs no form and no script β just a state-changing endpoint exposed over GET. A single tag on any page the victim loads fires a credentialed request:
Because <img> issues a credentialed GET (Chapter 2), the browser attaches the victim's cookies and the unsubscribe happens. display:none hides the broken-image icon so there's no visual tell. This works only because the endpoint violated the rule that GET must be side-effect-free β which is exactly why that rule exists.
Level 2: POST Forgery with an Auto-Submitting Form
Most state changes are (correctly) POST, which an <img> can't produce. The standard attack is a hidden form that submits itself on load β the canonical CSRF payload:
The form's action points cross-site to the target; hidden inputs supply the attacker-chosen parameters; onload auto-submits so no click is needed. Changing the victim's account email is a favourite because it often enables a follow-up password reset β a foothold for full account takeover.
Level 3: Forgery via fetch (and its limits)
An attacker can also use fetch, but Chapter 2's rules bite here. To send cookies cross-site they must opt in, and they're confined to "simple" requests or they'll trigger a blocking preflight:
Note what the attacker cannot do: set a custom header, or send application/json β either triggers a preflight their origin can't pass. So fetch gives them no more power than a form for classic targets; the form is usually simpler and works even with scripting disabled. This limitation is the seed of several defences.
GET vs POST Forgery, Side by Side
| GET forgery | POST forgery | |
|---|---|---|
| Payload | <img>, <script>, link, or even a bare URL | auto-submitting hidden <form> |
| Needs JS? | no β a tag alone fires it | no β onload/no-JS submit works too |
| Requires | the endpoint to change state on GET (a bug) | any cookie-authorized POST endpoint |
| Stealth | trivial β hidden image, no navigation | page briefly navigates unless framed/hidden |
Delivery: Getting the Victim to the Payload
The forgery only fires if the logged-in victim loads the attacker's page. Common delivery vectors:
- A link in an email, message, or forum post leading to the attacker's page.
- A malicious or compromised ad (malvertising) on an otherwise-legitimate site.
- An embedded resource β for GET attacks, the payload can be an
<img>posted into any site that allows user images (a forum signature, a comment). - The victim simply being logged in elsewhere β they don't need to "do" anything beyond having an active session and opening the page.
What the Attacker Still Can't Do
Keeping the limits sharp (from Chapter 2) helps you scope risk correctly:
- Can't read responses β so they can't exfiltrate data this way; CSRF is about causing actions.
- Can't forge requests needing unpredictable values β a secret token they can't guess blocks the request entirely (Chapter 5).
- Can't set custom headers or JSON bodies cross-site without a preflight β limiting attacks to simple requests.
- Can't act if the cookie isn't sent β SameSite (Chapter 7) can strip the credential from the forged request.
Each "can't" is a defence the rest of the course makes concrete. Next we clear up the most-confused boundary in web security: CSRF versus XSS versus SSRF.
Hands-On Exercises
On a deliberately-vulnerable training app (Juice Shop / DVWA / PortSwigger labs), build a Level-1 GET-forgery payload and a Level-2 auto-submitting POST payload against a state-changing endpoint. For each, state exactly which endpoint property made it possible.
π View solutionExplain how a single forged "change email address" request can escalate into full account takeover. Then explain why an attacker can perform the change but cannot directly read the confirmation response, and why that doesn't save the victim.
π View solutionAn attacker tries a cross-site fetch to POST a JSON body with a custom X-Requested-With header to the target. Walk through what the browser does and why this forgery fails β then explain what it would (wrongly) take for the target to be safe relying on that alone.
Chapter 3 Quick Reference
- GET forgery β a hidden
<img>/tag fires a credentialed GET; works only against a state-changing GET (a bug) - POST forgery β an auto-submitting hidden
<form>(onload submit); the canonical CSRF payload, no click needed - fetch forgery β needs
credentials:'include'and is confined to simple requests (no custom headers / JSON without a preflight) - Change-email / change-password endpoints are prime targets β one forged change can chain to account takeover
- Delivery β links, malvertising, embedded
<img>in user content; victim just needs an active session + to load the page - Closing a tab doesn't help (persistent cookie); logging out does (invalidates the session)
- Attacker still can't: read responses Β· forge unpredictable tokens Β· set custom headers/JSON cross-site Β· act if the cookie isn't sent
- Practise only on owned/training targets β Juice Shop, DVWA, PortSwigger Academy
- Next chapter: CSRF vs XSS vs SSRF β untangling the three most-confused web-security acronyms
CSRF vs XSS vs SSRF
These three acronyms look alike, get muddled constantly, and have completely different mechanisms and defences. Confusing them leads to applying the wrong fix β the dangerous "I added CORS, so I'm safe from CSRF" class of mistake. This mid-course checkpoint pins down each one precisely and, just as importantly, how they relate.
One-Line Definitions
| Acronym | Full name | In one line |
|---|---|---|
| CSRF | Cross-Site Request Forgery | tricks the victim's browser into sending an unwanted request using the victim's authority |
| XSS | Cross-Site Scripting | runs the attacker's JavaScript inside the victim's session on the target site |
| SSRF | Server-Side Request Forgery | tricks the server into making requests to places the attacker chooses |
The clearest way to keep them apart is to ask who is tricked into acting: CSRF tricks the browser, XSS injects script that runs in the page, SSRF tricks the server. Two are "cross-site," one is "server-side" β and that word swap (Scripting vs Request Forgery) is where most confusion starts.
CSRF vs XSS β The Pair People Mix Up Most
Both involve a victim and a malicious payload, but the capability gap is enormous:
| CSRF | XSS | |
|---|---|---|
| What runs where | no attacker code on the target; a request is sent to it from another site | attacker's script runs on the target page, same-origin |
| Can read the response? | No (same-origin policy blocks it) β write-only | Yes β it's same-origin, so it reads everything |
| Can steal data / cookies? | No | Yes (cookies, tokens, page content, keystrokes) |
| Typical defence | anti-CSRF tokens, SameSite cookies | output encoding, CSP, input sanitization |
What XSS Looks Like (Briefly)
XSS happens when untrusted input is rendered into a page without proper encoding, so it executes as code rather than displaying as text:
The fix is the opposite discipline from CSRF's: encode output so input can never become executable markup, plus a Content-Security-Policy to restrict what scripts may run. Note these defences have nothing to do with tokens or SameSite β different bug, different toolbox.
SSRF β The Odd One Out
SSRF is server-side and unrelated to the victim's browser at all. The attacker abuses a server feature that fetches a URL β an "import from URL," a webhook, an image-from-link β by supplying a URL pointing somewhere the server can reach but the attacker normally can't:
Classic SSRF targets internal-only services: cloud metadata endpoints, internal admin panels, databases β things firewalled from the internet but reachable from the server itself. The defence is again wholly different: validate/allowlist outbound URLs, block internal IP ranges, and isolate the metadata endpoint. SSRF shares the words "Request Forgery" with CSRF but the forger is the server, not the browser β opposite side of the connection.
The Memory Hook
How They Can Combine
They're distinct, but real attacks chain them:
- XSS β CSRF isn't even needed β XSS can do everything CSRF can and more, same-origin. (This is why XSS outranks CSRF in severity.)
- CSRF β SSRF β a CSRF that hits an internal admin endpoint, or that triggers a server-side fetch feature, can become a delivery vector for SSRF.
- Defence independence β because the mechanisms differ, you need all three sets of defences; fixing one does nothing for the others. A token-protected app can still be wide open to XSS or SSRF.
Hands-On Exercises
For each scenario, name the vulnerability (CSRF / XSS / SSRF): (a) a forum signature containing <script> that steals viewers' cookies; (b) a hidden form on evil.com that changes your email on a site you're logged into; (c) a "fetch preview of this URL" feature an attacker points at http://localhost:8080/admin. Justify each with the "who acts?" test.
Explain precisely why a site with an XSS vulnerability cannot be protected from CSRF by synchronizer tokens alone. Walk through what the injected script does to the token, and state the security principle this implies about defence ordering.
π View solutionBuild a comparison table of CSRF, XSS, and SSRF across four axes: who is tricked into acting, whether attacker code runs on the target, whether data can be read/exfiltrated, and the primary defence. Then explain why "I enabled CORS" addresses none of them as a CSRF fix.
π View solutionChapter 4 Quick Reference
- CSRF β victim's browser tricked into sending an unwanted request with the victim's authority; write-only
- XSS β attacker's script runs same-origin on the target; can read everything, steal cookies/tokens
- SSRF β the server tricked into fetching attacker-chosen URLs (internal services, cloud metadata)
- Disambiguate by "who is tricked into acting?" β script on page (XSS) Β· browser sends request (CSRF) Β· server sends request (SSRF)
- XSS defeats CSRF defences β same-origin script reads the token; fix XSS first, it's strictly more powerful
- Defences are independent & non-transferable: tokens/SameSite (CSRF) Β· output encoding/CSP (XSS) Β· URL allowlist/block internal IPs (SSRF)
- Attacks chain: CSRFβSSRF possible; XSS subsumes CSRF β you need all three defence sets
- Next chapter: the synchronizer token pattern β the classic CSRF defence, in detail (and your admin-login error)
The Synchronizer Token Pattern
Now the defences. The synchronizer token pattern is the oldest and most robust CSRF defence, and the one behind the "Invalid CSRF token" message you hit on your admin login. It works by attacking precondition #2 from Chapter 1 β forgeable parameters β by requiring every state-changing request to carry a secret the attacker cannot know.
The Core Idea
The server generates a random, unpredictable token, ties it to the user's session, and embeds it in every form it serves. A genuine request (from the server's own page) includes the token; a forged request (from evil.com) cannot, because the attacker can't read the token out of your page (Same-Origin Policy, Chapter 2). The server rejects any state-changing request whose token is missing or wrong.
The Lifecycle: Generate β Embed β Submit β Validate
What It Looks Like in Code (Express)
The token must be sent in the request body or a header β never only in a cookie, because a cookie auto-rides on forged requests too (that's the whole problem). The defining property is that the token travels somewhere the attacker would have to read or write, which cross-site they cannot.
Per-Session vs Per-Request Tokens
| Per-session token | Per-request token | |
|---|---|---|
| Lifetime | one token for the whole session | new token after every request |
| Pros | simple; tolerant of back button & multiple tabs | tighter; limits token-replay window |
| Cons | longer-lived secret | fragile β back button & multiple tabs cause mismatches |
This table is the crux of your login mystery. Per-request rotation is more secure but notoriously breaks the back button and multi-tab usage, because an old page holds a token the server has already rotated away from.
Generating a Good Token
The token's security rests entirely on being unguessable. Use a cryptographically secure random generator, not Math.random():
Compare tokens with a constant-time comparison (e.g. crypto.timingSafeEqual) to avoid leaking information through timing. And validate on every state-changing request β a single unprotected POST endpoint is all an attacker needs.
Hands-On Exercises
Implement a minimal synchronizer-token defence in Express: generate a token at session start, embed it as a hidden _csrf field, and validate it on a POST route with a constant-time comparison. Explain why the token must be in the body/header and not only in a cookie.
Explain, step by step, why your admin-login "Invalid CSRF token" error occurs and why the browser's Back button clears it. Identify the three likely root causes (stale cached form, session regeneration on login, per-request rotation) and why Firefox's bfcache makes it more visible.
π View solutionFor each flawed implementation, explain how an attacker defeats it: (a) the token is stored only in a second cookie and the server checks the two cookies match; (b) only POST routes validate the token while GET /delete?id= exists; (c) the token is generated with Math.random(). State the fix for each.
Chapter 5 Quick Reference
- Synchronizer token β server issues a random token tied to the session; every state-changing request must echo it back
- Works by attacking forgeable parameters β the attacker has the cookie but can't read the token (cross-site)
- Lifecycle: generate β embed (hidden field) β submit (body/header) β validate against the session's stored token
- Token must travel in the body or a header, never only a cookie (a cookie auto-rides forged requests)
- Per-session (simple, back-button friendly) vs per-request (tighter, but breaks back button & multi-tab)
- Your login error = a stale token (cached/bfcached form, session regenerated on login, or per-request rotation); Back reloads a matching form
- Generate with a CSPRNG (
crypto.randomBytes), compare in constant time, validate on every state-changing route - Defeated by: unguarded GETs Β· token-only-in-cookie Β· weak token Β· uncovered endpoints Β· XSS (reads the token)
- Next chapter: double-submit cookie & stateless tokens β defending without server-side token storage
Double-Submit Cookie & Stateless Tokens
The synchronizer token (Chapter 5) requires the server to store a token per session. That's fine for stateful apps, but adds friction for stateless backends and APIs that don't keep server-side session state. The double-submit cookie pattern achieves CSRF protection without server-side storage β at the cost of some subtle pitfalls this chapter makes explicit.
The Core Trick
The server sends the CSRF token as a cookie, and the client-side JavaScript reads that cookie and copies its value into a request header (or body field) on each state-changing request. The server then checks that the cookie value and the header value match. No server-side token store is needed β the server just compares the two copies the request itself carries.
Why It Stops CSRF
The cookie rides automatically on a forged cross-site request (ambient authority) β but the attacker cannot read it to copy its value into the header, because of the Same-Origin Policy (Chapter 2). So a forged request arrives with the cookie but no matching header, and validation fails. The defence rests entirely on the asymmetry: the cookie is auto-sent, but only same-origin JavaScript can read it to set the header.
Synchronizer vs Double-Submit
| Synchronizer token | Double-submit cookie | |
|---|---|---|
| Server-side storage | required (token per session) | none β stateless |
| Validation | submitted token vs stored token | cookie copy vs header copy |
| Best fit | stateful, server-rendered apps | stateless backends, SPAs/APIs |
| Main weakness | storage cost; per-request fragility | subdomain / cookie-injection issues |
The Big Pitfall: Subdomains Can Write Your Cookie
Double-submit's security assumes the attacker can't control the cookie value. But cookies have a weaker isolation model than the same-origin policy: a sibling or compromised subdomain can set a cookie on the parent domain. If evil.example.com (or an XSS on any subdomain) can write the csrf cookie for .example.com, an attacker can plant a known value β then put that same known value in the header of a forged request, and the two match.
csrf-csrf) implement this signed variant β don't roll the naive version yourself.
The Signed / HMAC Variant
The hardened form binds the token to the user's session so it can't be forged by merely setting a cookie:
Now the server doesn't blindly trust the cookie; it verifies the token is one it issued for this session. This restores the property that an attacker who can only set a cookie (but not read the session secret) can't produce a valid token β closing the cookie-injection hole while staying stateless.
The SPA-Friendly Variant You've Probably Seen
Frameworks like Angular and Django (with the right config) ship a double-submit flow out of the box: the server sets a cookie named XSRF-TOKEN, and the framework's HTTP client automatically reads it and sends it back as an X-XSRF-TOKEN header on every request. If you've used Angular's HttpClient and wondered where CSRF protection came from, this is it β double-submit, automated.
Hands-On Exercises
Implement a basic double-submit cookie check in Express (set a JS-readable token cookie; validate that an X-CSRF-Token header equals the cookie). Then explain precisely why a cross-site forged request fails this check despite the cookie being auto-sent.
Describe the subdomain / cookie-injection attack that defeats naive double-submit. Walk through how an attacker controlling evil.example.com can make a forged request pass the cookie==header check, and explain how the signed/HMAC variant prevents it.
Compare synchronizer and double-submit on storage, statelessness, and main weakness, and recommend which to use for (a) a server-rendered Rails app and (b) a stateless JSON API behind a SPA. Then explain why the CSRF token cookie is intentionally not HttpOnly and when that becomes dangerous.
π View solutionChapter 6 Quick Reference
- Double-submit cookie β token sent as a cookie; client copies it into a header; server checks cookie == header. No server storage.
- Stops CSRF because the cookie auto-rides but the attacker can't read it (SOP) to set the matching header (which also needs a preflight)
- Favour a custom header over a form field β adds the CORS preflight barrier on top of the read barrier
- Best for stateless backends, SPAs, and APIs; synchronizer is better for stateful server-rendered apps
- Pitfall: a subdomain / sibling XSS / non-HTTPS attacker can write the cookie β plant a known value β naive cookie==header is forgeable
- Fix: the signed / HMAC variant binds the token to the session, so a planted cookie value the server didn't issue fails validation
- Angular (
XSRF-TOKENβX-XSRF-TOKEN) and Django ship automated double-submit - The CSRF token cookie is intentionally not HttpOnly (JS must read it); the session cookie still should be β and this is safe only absent XSS
- Next chapter: SameSite cookies β how a browser-level attribute changed the whole CSRF landscape
SameSite Cookies
Tokens (Chapters 5β6) defend by adding a secret to the request. SameSite attacks CSRF from a different angle β precondition #3 (Chapter 1): it tells the browser not to send the cookie cross-site in the first place. It's a one-line cookie attribute that, combined with a 2020 change to browser defaults, reshaped the entire CSRF landscape.
The Attribute
SameSite is set on a cookie and controls whether the browser attaches it to cross-site requests. It has three values:
| Value | Cookie sent on same-site requests? | Sent on cross-site requests? |
|---|---|---|
| Strict | Yes | Never β not even top-level navigation |
| Lax | Yes | Only on top-level GET navigations (clicking a link) |
| None | Yes | Yes (legacy behaviour) β requires Secure |
How Each Value Affects CSRF
- Strict β the cookie is never sent on any cross-site request, so forged requests arrive with no session cookie at all β CSRF fully blocked. The cost: even a legitimate inbound link from another site (e.g. an email link to your dashboard) arrives logged-out, which hurts UX.
- Lax β the cookie is sent on top-level GET navigations (so following a link to your site keeps you logged in) but not on cross-site POSTs, forms targeting your site, or background subresource requests. This blocks the classic POST-form CSRF while preserving normal link UX β the sweet spot, and now the default.
- None β the old behaviour: cookie sent on all cross-site requests. This is what CSRF exploited. Now requires
Secure, and is only appropriate for cookies that genuinely need cross-site use (e.g. third-party embeds, SSO).
SameSite attribute defaulted to None (send everywhere) β which is why CSRF was so easy. In 2020, Chrome (then other browsers) changed the default for cookies without an explicit SameSite to Lax. Overnight, the most common CSRF vector β a cross-site POST form β stopped carrying the session cookie by default, on most of the web, with no developer action. This is why CSRF is less dangerous today than a decade ago. But "less" is not "gone," and relying on the default alone is risky (see below).
Why Lax Stops the Classic Attack
Recall the canonical attack (Chapter 3): an auto-submitting hidden POST form on evil.com. With SameSite=Lax, the browser will not attach the session cookie to that cross-site POST, because Lax only sends cookies on top-level GET navigations β not POSTs, not subresource requests. The forged POST arrives unauthenticated, and the server treats it as a logged-out request. The hidden-form workhorse of CSRF is neutralized by a single cookie attribute.
a.example.com attacking b.example.com is "same-site" and not blocked. (5) Older browsers may ignore it. So use SameSite and tokens β defence in depth (Chapter 8).
"Site" vs "Origin" β a Crucial Distinction
SameSite operates on the concept of site (the registrable domain, e.g. example.com), not origin (scheme + host + port). This is looser than the Same-Origin Policy:
| Request from β to | Same-origin? | Same-site? |
|---|---|---|
app.example.com β app.example.com | Yes | Yes |
blog.example.com β app.example.com | No | Yes (same registrable domain) |
evil.com β app.example.com | No | No |
The middle row is the catch: a sibling subdomain is same-site, so SameSite won't stop a malicious or compromised subdomain β which ties directly to the cookie-injection concern from Chapter 6. SameSite defends the cross-site boundary, not the cross-origin-but-same-site one.
Practical Recommendation
For a typical session cookie: SameSite=Lax (the default, but set it explicitly), plus Secure and HttpOnly. Use Strict for the highest-sensitivity cookies where the "arrive logged out from external links" cost is acceptable (or pair Strict with a Lax "read" cookie). Reserve None for cookies that truly require cross-site sending, always with Secure. And β the recurring theme β layer it with anti-CSRF tokens rather than relying on SameSite alone.
Hands-On Exercises
In DevTools (Application β Cookies), inspect the SameSite value of cookies on a few sites. Then explain, for a SameSite=Lax session cookie, exactly which of these carry it: (a) clicking a link from another site, (b) a cross-site auto-submitting POST form, (c) a cross-site <img> GET.
Explain how the 2020 change of the default SameSite value (None β Lax) reduced CSRF risk across the web without developers doing anything. Then give two reasons you should still set SameSite explicitly and still use tokens.
π View solutionA developer sets SameSite=Lax and declares the app "CSRF-proof." Identify the gaps that leave it still attackable β covering state-changing GETs, the site-vs-origin distinction (sibling subdomains), and SameSite=None cookies β and state what to add to actually close them.
Chapter 7 Quick Reference
- SameSite β cookie attribute controlling whether the cookie is sent on cross-site requests (attacks CSRF precondition #3)
- Strict β never sent cross-site (blocks CSRF fully; external links arrive logged-out)
- Lax β sent only on top-level GET navigations; blocks cross-site POST forms while keeping link UX (the default & sweet spot)
- None β sent on all cross-site requests (legacy CSRF-enabling behaviour); now requires Secure
- 2020 default flip (None β Lax) hugely reduced CSRF across the web automatically β but "less" β "gone"
- Gaps: state-changing GETs Β· Lax+POST grace window Β· SameSite=None cookies Β· sibling subdomains Β· old browsers
- Site β origin β SameSite uses the registrable domain, so
a.example.comβb.example.comis "same-site" and unprotected - Recommendation: session cookie = Lax + Secure + HttpOnly (Strict for high-sensitivity), and tokens β defence in depth
- Next chapter: defence in depth β custom headers, Origin/Referer checks, re-authentication, combining layers
Defence in Depth
Each defence so far has gaps: tokens can be misimplemented, SameSite has the site-vs-origin and GET holes, double-submit has cookie injection. Defence in depth is the discipline of layering independent mechanisms so that an attacker must defeat all of them β and a single misconfiguration doesn't expose you. This chapter covers the supplementary defences and how to stack them.
Checking the Origin / Referer Header
On a cross-site request, the browser sets an Origin header (and usually Referer) identifying the page that initiated it β and the attacker's page cannot forge or remove these on a cross-site request (they're browser-controlled). So the server can simply check: did this state-changing request come from my own origin?
Origin header is usually present on state-changing cross-site requests but historically wasn't on every request type, and Referer can be stripped by privacy settings, proxies, or Referrer-Policy β so a strict "reject if absent" can break legitimate clients, while "allow if absent" opens a hole. (2) It does nothing for the same-site sibling problem (Chapter 7) β a request from blog.example.com may carry an allowed-looking origin. Treat Origin/Referer checks as a strong corroborating layer alongside tokens + SameSite, not a replacement. Match against an explicit allowlist, exact-string, never a loose substring.
Requiring a Custom Request Header
From Chapter 2: a cross-site attacker cannot set a custom header on a simple request without triggering a CORS preflight they can't pass, and an HTML form can't set headers at all. So requiring a header like X-Requested-With: XMLHttpRequest on state-changing endpoints means a classic form/img forgery simply can't produce a valid request.
Re-Authentication & Step-Up for Sensitive Actions
The strongest layer for the highest-value actions doesn't rely on request shape at all: require the user to prove intent directly β re-enter their password, confirm with a one-time code, or pass an MFA challenge β for operations like changing email/password, deleting an account, or making a payment.
This defeats CSRF by construction: even a perfectly forged request can't supply the current password or live MFA code, because the attacker doesn't know them (Chapter 3's "account takeover via change-email" is exactly what this stops). It's friction, so reserve it for genuinely sensitive operations β but for those, it's the most robust defence available.
The Layered Stack
A well-defended state-changing endpoint combines several independent checks β an attacker must beat every one:
What to Actually Ship
For most apps the pragmatic, strong baseline is: SameSite=Lax + anti-CSRF tokens (via a maintained library) + safe-GET discipline, with Origin checks as an easy extra layer and re-authentication on the handful of truly sensitive endpoints. You don't need every layer everywhere β you need enough independent ones that no single failure is catastrophic, weighted toward your highest-value actions.
Hands-On Exercises
Implement an Origin/Referer check middleware in Express that allows only your own origin on state-changing requests. Then explain two reasons it must not be your only CSRF defence (header absence handling, and the same-site sibling gap).
π View solutionExplain why requiring re-authentication (re-entering the password) on a "change email" endpoint defeats CSRF even if every other defence were somehow bypassed. Connect this back to the account-takeover chain from Chapter 3.
π View solutionA team proposes "defence in depth": session cookie, a second auth cookie, and a CSRF cookie all checked server-side. Explain why this is not real defence in depth, then design a genuinely independent 3-layer stack and state the distinct assumption each layer relies on.
π View solutionChapter 8 Quick Reference
- Defence in depth β layer independent mechanisms so no single failure exposes you
- Origin/Referer check β reject state-changing requests not from your allowlisted origin; browser-set, attacker can't forge cross-site
- Origin caveats: header may be absent (proxies/Referrer-Policy) and won't catch same-site siblings β a layer, not sole defence
- Custom required header β forms can't set headers; cross-site fetch triggers a preflight β blocks classic forgery (enforce strictly)
- Re-authentication / MFA for sensitive actions β defeats CSRF by construction (attacker lacks the password/OTP); reserve for high-value ops
- Strong stack: SameSite + token + Origin check + safe-GET + re-auth on sensitive endpoints
- Layers must fail for different reasons β five cookie-trusting checks aren't depth; and nothing survives XSS
- Ship baseline: SameSite=Lax + tokens (library) + safe-GET, plus Origin checks and re-auth where it matters
- Next chapter: CSRF in modern apps β SPAs, JSON APIs, bearer vs cookie auth, framework built-ins
CSRF in Modern Apps
Classic CSRF assumed server-rendered pages and cookie sessions. Modern apps β SPAs talking to JSON APIs, mobile clients, token-based auth β change the picture significantly. The single most important question for any endpoint is: how does it authenticate the request? Because that answer determines whether CSRF even applies.
The Decisive Question: Cookies or Not?
CSRF exists only because credentials are sent automatically (ambient authority, Chapter 1). So the dividing line is whether your auth credential is auto-attached by the browser:
| Auth method | Credential sent automatically? | CSRF-vulnerable? |
|---|---|---|
| Cookie session | Yes β browser attaches it | YES β needs CSRF defences |
| Authorization: Bearer <token> | No β JS must add it explicitly | No β not classic CSRF |
Authorization: Bearer header that your JavaScript must explicitly attach to each request, then a cross-site attacker's forged request won't carry it β the browser doesn't auto-attach Authorization headers, and the attacker can't read the token to add it (Chapter 2). So a purely header-token API is not vulnerable to classic CSRF. The catch is in the word purely: the moment a token lives in a cookie, or you use any cookie-based session, CSRF is back on the table.
Where Teams Get This Wrong: JWT in a Cookie
A very common pattern is "we use JWTs, so we're stateless and CSRF-safe." But where the JWT is stored decides everything:
| JWT stored in⦠| Auto-sent? | CSRF risk | XSS risk to token |
|---|---|---|---|
| Authorization header (from JS memory) | No | No classic CSRF | readable by XSS |
| Cookie | Yes | CSRF-vulnerable (needs defences) | HttpOnly hides from XSS |
HttpOnly and survives reloads), the browser auto-sends it on cross-site requests exactly like a session cookie β so you have full classic CSRF exposure and must add tokens/SameSite. If you put it in an Authorization header, you avoid classic CSRF but the token is reachable by XSS. There's a genuine trade-off (CSRF exposure vs XSS exposure), but there is no "JWT = safe." Decide based on storage, not format. This is the most common modern misconception about CSRF.
The "JSON-Only / Custom Header" API Defence
Many JSON APIs lean on the Chapter 2 fact that a forged HTML form can't set headers and can't send application/json without a preflight. So an API that requires Content-Type: application/json and rejects everything else (or requires a custom header) blocks classic form-based CSRF:
application/x-www-form-urlencoded reopens the hole, because a form can send that), and it assumes well-behaved CORS. If the endpoint uses cookie auth, do not rely on JSON-only as the sole CSRF defence β pair it with SameSite + tokens. With header-bearer-token auth it's moot (no CSRF anyway). The point: "we only accept JSON" is a useful layer, not a guarantee.
SPA + Cookie Session: The Double-Submit Sweet Spot
A very common modern setup is an SPA that still uses a cookie session (often the simplest, most secure option since the session cookie can be HttpOnly). Here the double-submit cookie (Chapter 6) fits perfectly: the SPA's HTTP client reads the XSRF-TOKEN cookie and echoes it in an X-XSRF-TOKEN header automatically. This is exactly what Angular's HttpClient and Axios (with config) do out of the box.
Framework Built-Ins β Don't Roll Your Own
| Framework | Built-in CSRF protection |
|---|---|
| Django | CsrfViewMiddleware + {% csrf_token %} (synchronizer/double-submit hybrid); on by default |
| Rails | protect_from_forgery + form_authenticity_token; on by default |
| Spring Security | CSRF tokens enabled by default for browser clients |
| Express | no built-in; use a maintained lib (csrf-csrf; the old csurf is deprecated) |
| Angular | client-side XSRF-TOKEN β X-XSRF-TOKEN double-submit, automatic |
csurf package is a cautionary tale β prefer actively maintained options like csrf-csrf.
Decision Guide
- Cookie/session auth (SSR or SPA) β SameSite + anti-CSRF tokens (synchronizer for SSR, double-submit for SPA). CSRF fully applies.
- Header bearer token (no cookies) β no classic CSRF; focus on XSS (token theft) and token handling instead.
- JWT in a cookie β treat exactly like a session cookie: full CSRF defences required.
- Mobile / native client (no browser) β no CSRF (no ambient cookie behaviour); standard token auth.
Hands-On Exercises
For each API, state whether classic CSRF applies and why: (a) auth via Authorization: Bearer header from JS memory; (b) auth via a session cookie; (c) auth via a JWT stored in a cookie; (d) a native mobile app sending a token header. Tie each answer to "is the credential auto-sent by the browser?"
Explain why "we use JWTs so we're CSRF-safe" is wrong. Contrast JWT-in-header vs JWT-in-cookie on both CSRF exposure and XSS exposure, and state the trade-off a team is actually making when they choose cookie storage for HttpOnly.
π View solutionAn API blocks CSRF by requiring Content-Type: application/json. Describe two ways this can still fail (a permissive parser that also accepts form encoding; cookie auth with lax enforcement), and give the correct layered configuration for a cookie-authenticated SPA.
Chapter 9 Quick Reference
- The decisive question: is the auth credential auto-sent by the browser? Cookie = yes (CSRF applies); header bearer token = no
- Authorization: Bearer (added by JS) β not classic CSRF; the attacker can't auto-send or read it
- "We use JWT" β safe β JWT in a cookie is auto-sent β full CSRF exposure; JWT in a header avoids CSRF but is XSS-readable
- Trade-off: cookie storage (HttpOnly, CSRF-exposed) vs header storage (XSS-exposed, no CSRF) β choose by storage, not format
- JSON-only / custom-header API defence works only with strict rejection of other content types; not sole defence under cookie auth
- SPA + cookie session β double-submit (Angular/Axios
XSRF-TOKENβX-XSRF-TOKEN) is the sweet spot - Use framework built-ins (Django/Rails/Spring on by default; Express use
csrf-csrf, not deprecatedcsurf) β don't hand-roll - Mobile/native clients: no browser ambient cookies β no CSRF
- Next chapter: testing, pitfalls & a hardening checklist β finding CSRF and the deployable summary (incl. your login-error fix)
Testing, Pitfalls & Checklist
The final chapter turns understanding into practice: how to test for CSRF on your own apps, the broken-defence patterns that look protected but aren't, a copy-pasteable hardening checklist β and the proper, deployable fix for the admin-login token error that started this whole course.
How to Test for CSRF
Testing your own endpoints follows a simple manual procedure (and tools automate it):
- Capture a legitimate state-changing request (DevTools β Network, or Burp Suite's proxy).
- Identify the auth β is it a cookie (CSRF-relevant) or a header token (not classic CSRF)? (Chapter 9)
- Strip the anti-CSRF token (and any custom header) and replay the request with only the cookie. If it still succeeds β vulnerable.
- Try a forged page β build an auto-submitting form (Chapter 3) on a different origin, load it while logged in, and see if the action fires.
- Test the token's strength β does the server accept an empty token? Another user's token? A token after it should have rotated?
The Broken-Defence Catalogue
These look protected but aren't β the patterns to hunt for in review:
| Broken defence | Why it fails | Chapter |
|---|---|---|
| Token rendered but never validated | server embeds it, never checks it on POST | 5 |
| Token only in a cookie | auto-rides forged requests; not the synchronizer pattern | 5 |
| State change on GET | forgeable by a bare <img>; SameSite=Lax still sends on top-level GET | 2,3,7 |
| Naive double-submit | cookie injection from a subdomain plants a known value | 6 |
| Validation on some routes only | one unprotected endpoint is enough | 5 |
| "CORS protects us" | CORS governs reads, not whether the request is processed | 2,4 |
| "We use JWT" | JWT in a cookie is auto-sent = full CSRF | 9 |
| Token accepted when empty/blank | missing-token check treats absent as valid | 5 |
The Hardening Checklist
__Host- cookie prefix if stateless; don't ship the naive variant- Stop caching the login form. Send
Cache-Control: no-store(andno-cache) on the login page so Firefox's bfcache doesn't restore a stale page carrying an out-of-date token. This is the most likely single fix given it's Firefox-specific. - Issue the token after session regeneration. If login rotates the session (anti-fixation), render/refresh the CSRF token after the new session exists, so the form's token matches.
- Prefer a per-session token for the login form (or one that tolerates refresh) rather than aggressive per-request rotation, which is what breaks on back/multi-tab.
- Graceful handling: on a token mismatch for the login specifically, re-render the login page with a fresh token instead of a hard 403 β so the user never sees the error.
Hands-On Exercises
Write a step-by-step manual test plan to determine whether a state-changing endpoint is CSRF-vulnerable, including the "remove the token and replay" check and a forged-page test. State what result at each step indicates a vulnerability.
π View solutionGiven the broken-defence catalogue, audit this app: it validates tokens on POST only, exposes GET /admin/delete?id=, stores its CSRF value only in a cookie, and the team says "CORS protects the API." List every CSRF flaw and the fix for each.
Write the complete, ordered fix for the admin-login "Invalid CSRF token" error: the headers to add, the session/token lifecycle change, and the graceful-handling behaviour. Explain why each addresses token staleness rather than weakening protection, and why the bug appeared in Firefox.
π View solutionChapter 10 Quick Reference
- Test by replay β remove the token/custom header, resend with just the cookie; if it works, the token isn't validated
- Tools: Burp Suite (CSRF PoC generator), OWASP ZAP; manual forged-page test from another origin
- Broken defences: token rendered-but-unvalidated Β· token-only-in-cookie Β· state-changing GET Β· naive double-submit Β· partial coverage Β· "CORS/JWT protects us" Β· empty-token accepted
- Checklist: tokens everywhere (library) Β· SameSite+Secure+HttpOnly Β· safe-GET Β· Origin check Β· re-auth on sensitive ops Β· prevent XSS Β· signed double-submit +
__Host-Β· test by replay - Login-error fix:
Cache-Control: no-storeon the login page Β· issue token after session regeneration Β· per-session token Β· re-render gracefully on mismatch - It's a stale token, not an attack β fix the staleness; never disable the check or loosen SameSite to "fix" it
- Test across browsers β Firefox's bfcache made the stale-token issue visible where others may differ
β CSRF β Cross-Site Request Forgery Complete β 10 / 10 chapters
From the confused-deputy mechanism and ambient authority, through the attack anatomy, the CSRF/XSS/SSRF distinction, every defence (synchronizer & double-submit tokens, SameSite, Origin checks, re-auth), modern SPA/API/JWT considerations, and a deployable testing checklist. You can now recognize CSRF, defend against it in depth, audit broken defences β and you've fully diagnosed and fixed the admin-login token error that started it all.