L33 · Cross-site request forgery (CSRF)
Module 5: Web Application Security · ~20 min · Lesson 33 of 71
🎯 What you'll learn
- How a page on another site can make your browser send a request to your bank, cookies included
- Why SameSite cookies made the classic attack much rarer, and where they still fall short
- The defences that work: CSRF tokens, plus Origin and Fetch Metadata checks
- Which of your apps are at risk: cookie-based portals and BFFs yes, pure bearer-token APIs mostly no
🔥 Why this matters
In 2008, Princeton researchers William Zeller and Edward Felten showed that ING Direct's banking site accepted requests started by any website. A logged-in customer who opened a malicious page could have an account opened in their name and money moved to the attacker. The bank's login was fine. The browser just attached the session cookie to every request to the bank, whichever site started it. ING fixed the bug after the report.
1. The browser is a confused deputy
Alice logs in to portal.example-bank.com in the morning, and her browser stores a session cookie
(L22). At lunch she follows a link to cats.example.net. That page contains a hidden form pointed
at her bank, and one line of JavaScript that submits it:
<form action="https://portal.example-bank.com/transfer"
method="POST">
<input type="hidden" name="to" value="ACC-666">
<input type="hidden" name="amount" value="5000">
</form>
<script>document.forms[0].submit()</script>
Alice's browser cats.example.net portal.example-bank.com
│ │ │
│ 1. log in (morning) │ │
│──────────────────────────────────────────────▶│
│◀─────────── Set-Cookie: sid=K9f... ───────────│
│ │ │
│ 2. open cat page │ │
│──────────────────────▶│ │
│◀── hidden form + JS ──│ │
│ │ │
│ 3. POST /transfer to=ACC-666 amount=5000 │
│ Cookie: sid=K9f... (added by the browser) │
│──────────────────────────────────────────────▶│
│ │ 4. session valid, so │
│ │ transfer executes │
This is cross-site request forgery (CSRF): a malicious site makes the victim's browser send a request to another site where the victim is logged in. The browser is a confused deputy, a program that holds authority (Alice's cookie) and is tricked into using it for someone else.
Cookies are ambient authority: credentials the browser attaches automatically, without the page asking. HTTP Basic auth, TLS client certificates and Windows integrated login (Kerberos or NTLM) are ambient too, so intranet apps aren't automatically safe.
A CSRF attack needs three ingredients:
- An action worth forcing: a transfer, an email change, a new payee.
- Ambient authentication: the server identifies the user only by something the browser sends on its own.
- A fully predictable request: the attacker can write every parameter in advance.
Remove any one and the attack fails. Note what the attacker can't do: the same-origin policy
stops cats.example.net from reading the bank's response. CSRF is blind: it's about doing, not
reading. XSS (L32) runs inside your origin and can do both, so no CSRF defence survives it.
2. SameSite changed the game (mostly)
L22 introduced the SameSite cookie attribute. With SameSite=Lax, the browser leaves the cookie
off cross-site POSTs, so step 3 above arrives without sid and the bank sees an anonymous request.
Since Chrome 80 (2020), Chromium-based browsers treat a cookie with no SameSite attribute as Lax.
That one default change killed most drive-by CSRF.
But SameSite answers a narrower question than it seems. A site is the scheme plus the registrable domain, which is bigger than an origin (scheme, full host and port):
request starts on to https://portal.example-bank.com
────────────────────────────── ─────────────────────────────────
https://portal.example-bank.com same-origin Lax cookie: sent
https://promo.example-bank.com same-site Lax cookie: sent
(sibling) Strict too: sent!
https://cats.example.net cross-site Lax: only on a
top-level GET
http://portal.example-bank.com cross-site (scheme differs)
So SameSite on its own leaves gaps:
- Defaults vary. Firefox and Safari don't apply the Lax-by-default rule (true as of this
writing; check current browser notes). Set
SameSiteexplicitly on every cookie. - Siblings count as same-site. A taken-over subdomain (L08) or an XSS bug on the marketing site sends Strict cookies happily. The first deep dive covers this.
- Lax allows top-level GET. Any GET that changes state is forgeable with a plain link (L09).
- Some apps need
SameSite=None(a portal in a partner's iframe, a SAML endpoint, L26). Those cookies get no protection at all.
That's why OWASP's CSRF cheat sheet treats SameSite as defence in depth, not as the main control.
3. Defence 1: a secret the attacker can't know
The synchronizer token pattern removes ingredient 3. The server creates a random CSRF token
for each session, stores it with the session, and puts it in every form as a hidden field (or hands
it to a single-page app, which sends it in a header such as X-CSRF-Token). Every state-changing
request must bring it back. The attacker's page can't read the token, because of the same-origin
policy, so it can't build a valid request.
Get the details right:
- Make it unpredictable (
secrets.token_urlsafe(32), L20) and compare in constant time. - Bind it to the session, so a token from the attacker's own account doesn't work for Alice.
- Never put it in a URL, where it leaks into logs and
Refererheaders. - Protect the login form too. In login CSRF, the attacker logs the victim into the attacker's account; anything the victim then saves, like a card number, lands where the attacker can see it.
No server-side session store? The double-submit cookie pattern puts the token in a cookie and a header, and the server checks they match. The naive version is weak: anyone who can plant cookies (a sibling subdomain again) can plant both. OWASP recommends the signed version, where the token is an HMAC (L17) over the session ID, so a planted value fails the check.
You rarely build this yourself. Spring Security enables CSRF protection by default for every
method except GET, HEAD, TRACE and OPTIONS, and Django has CsrfViewMiddleware. The common bug is
someone turning it off.
4. Defence 2: ask the browser where the request came from
Browsers label requests, and a web page's JavaScript can't change these labels:
Origin: the scheme, host and port of the page that started the request. Modern browsers send it on cross-origin requests and on POSTs.Sec-Fetch-Site, one of the Fetch Metadata headers:same-origin,same-site,cross-site, ornone(the user typed the URL or used a bookmark). All major browsers have sent it since Safari 16.4 in 2023, but only to HTTPS and localhost.
The check is a few lines. Note that it rejects same-site, which closes the sibling-subdomain gap:
SAFE = {"GET", "HEAD", "OPTIONS"}
TRUSTED = {"https://portal.example-bank.com"}
def cross_origin_guard(req):
if req.method in SAFE:
return # GET must not change state
site = req.headers.get("Sec-Fetch-Site")
if site is not None:
if site not in ("same-origin", "none"):
abort(403)
return
origin = req.headers.get("Origin")
if origin is not None and origin not in TRUSTED:
abort(403)
# neither header: not a modern browser page (curl, a service)
Go 1.25 (August 2025) ships this logic as http.CrossOriginProtection, and recent OWASP guidance
accepts Fetch Metadata as a primary defence for modern browsers. Many teams run it alongside tokens.
For the most sensitive actions (a new payee), add an MFA step (L22's step-up deep dive): a forged
request can't answer an MFA prompt.
POST /transfer Cookie: sid=K9f...
│
▼
┌─────────────────────────────┐
│ 1. safe method? │──yes──▶ handle (and never
│ GET / HEAD / OPTIONS │ change state here)
└─────────────┬───────────────┘
│ no
▼
┌─────────────────────────────┐
│ 2. Sec-Fetch-Site or Origin │──cross-site or──▶ 403
│ says it's from us? │ same-site
└─────────────┬───────────────┘
│ yes, or header absent
▼
┌─────────────────────────────┐
│ 3. CSRF token matches the │──no──▶ 403
│ one in the session? │
└─────────────┬───────────────┘
│ yes
▼
handle request
5. Which of your apps need this?
The question is always: does the browser attach the credential by itself?
| App | How the browser proves who it is | CSRF risk |
|---|---|---|
| Server-rendered portal | Session cookie | Yes |
| SPA + BFF (L25): tokens on the server | Session cookie to the BFF | Yes, the classic modern case |
SPA sending Authorization: Bearer from JS memory |
Header set by your code | No (worry about XSS instead) |
| Service-to-service through Kong (L23 client credentials) | Header, no browser | No |
Kong's session plugin or an OIDC plugin session in front of an API |
Cookie set by the gateway | Yes: the gateway made it cookie-based |
| Internal admin UIs (Airflow, database consoles) | Session cookie | Yes: the victim's browser is inside the network |
The last row surprises people. The attacker's page doesn't need to reach your intranet; your
employee's browser does, and it will happily post a form to airflow.internal.example.com. Airflow
2's webserver_config.py sets WTF_CSRF_ENABLED = True for this reason; leave it on.
🧪 Worked example: the BFF that turned CSRF off
A wealth portal's Spring Boot BFF logs users in with OAuth and keeps a JSESSIONID cookie. A
partner embeds the portal in an iframe, so the cookie is SameSite=None. When the SPA started
getting 403s, someone "fixed" it:
http
.csrf(csrf -> csrf.disable()) // "we're a REST API"
.oauth2Login(withDefaults()); // ...that logs in with
// a session cookie
server.servlet.session.cookie.same-site=none
The legacy payee endpoint accepts form fields. A customer visits a malicious page, and this arrives:
POST /beneficiaries HTTP/1.1
Host: wealth.example-bank.com
Origin: https://cats.example.net ◀ another site started it
Sec-Fetch-Site: cross-site ◀ the browser says so
Content-Type: application/x-www-form-urlencoded
Cookie: JSESSIONID=8C1F0A... ◀ sent: SameSite=None
name=Mallory&iban=XX00TEST0000000666
Every clue was there. Nobody checked it. The fix restores three layers:
http
.csrf(csrf -> csrf // 1. tokens back on
.csrfTokenRepository(
CookieCsrfTokenRepository.withHttpOnlyFalse()))
.oauth2Login(withDefaults());
# 2. Lax for the main app; serve the partner embed from a
# separate route and session instead
server.servlet.session.cookie.same-site=lax
And 3: adding a payee now needs MFA. The SPA reads the XSRF-TOKEN cookie and sends it back as an
X-XSRF-TOKEN header (Angular's HttpClient does this automatically). Caution: Spring Security 6
changed the token encoding, so an SPA needs a matching request handler; copy it from the
"Single-Page Applications" section of the Spring Security reference for your exact version. The
forged request now gets a 403: the attacker's page can't read the token, and can't send a custom
header cross-site without a CORS preflight (second deep dive).
🛠 Try it (optional, ~5 min)
Watch the browser label a forged request, on your own machine only. localhost and 127.0.0.1 are
different sites, so one can play the bank and the other the attacker. Save this as bank.py:
from http.server import BaseHTTPRequestHandler, HTTPServer
class Bank(BaseHTTPRequestHandler):
def do_GET(self): # "log in": set a cookie
self.send_response(200)
self.send_header("Set-Cookie",
"sid=demo123; HttpOnly; Path=/; SameSite=Lax")
self.end_headers()
self.wfile.write(b"logged in as alice\n")
def do_POST(self): # the "transfer"
n = int(self.headers.get("Content-Length", 0))
print("body :", self.rfile.read(n))
for h in ("Cookie", "Origin", "Sec-Fetch-Site"):
print(f"{h:14}:", self.headers.get(h))
self.send_response(204)
self.end_headers()
HTTPServer(("localhost", 8000), Bank).serve_forever()
In a separate empty folder, save the form from section 1 as evil.html, with the action changed to
http://localhost:8000/transfer. Then:
python3 bank.py # terminal 1
python3 -m http.server 9000 --bind 127.0.0.1 # terminal 2
Open http://localhost:8000/ in Chrome (you're "logged in"), then http://127.0.0.1:9000/evil.html.
Terminal 1 should print something like:
body : b'to=ACC-666&amount=5000'
Cookie : None
Origin : http://127.0.0.1:9000
Sec-Fetch-Site: cross-site
The request arrived without the cookie (Lax), carrying two labels that say where it came from.
Now delete ; SameSite=Lax, restart, log in again and load the evil page within two minutes.
Chrome will probably send the cookie (its "Lax+POST" grace period for cookies with no explicit
SameSite), and Firefox or Safari may send it however long you wait. That variation is why you set
the attribute explicitly and check the headers. Stop both servers with Ctrl+C.
🧠 Key takeaways
- CSRF works because browsers attach cookies (ambient authority) to requests that other sites start.
- It needs a state-changing action, ambient authentication and a predictable request; remove one.
- SameSite=Lax blocks most of it, but siblings are same-site, GETs still pass, and defaults vary.
- Primary defences: a session-bound CSRF token, and rejecting non-safe requests whose
Sec-Fetch-Site/Originisn't yours. Add MFA for the most sensitive actions. - If the browser holds a cookie, you need CSRF protection; pure bearer-header APIs mostly don't.
📖 Words to know
| Term | Meaning |
|---|---|
| CSRF | Cross-site request forgery: another site makes the victim's browser send an authenticated request |
| Ambient authority | Credentials the browser attaches automatically, such as cookies or Basic auth |
| Confused deputy | A program with authority that's tricked into using it on someone else's behalf |
| Same-origin policy | The browser rule that stops one origin from reading another origin's responses |
| Site vs origin | Site = scheme + registrable domain; origin = scheme + full host + port |
| Synchronizer token | A random per-session CSRF token the server stores and checks on every state change |
| Double-submit cookie | A token sent in both a cookie and a header; should be signed to resist planted cookies |
| Login CSRF | Forcing a victim to log in to the attacker's account |
| Fetch Metadata | Sec-Fetch-* request headers in which the browser describes a request's context |
Sec-Fetch-Site |
Says whether a request came from the same origin, the same site, another site, or the user |
⏭ Next time
L34 covers the top risk on the OWASP list, broken access control: what happens when changing
/accounts/123 to /accounts/124 in the URL just works.
Where SameSite falls short: siblings, GET handlers and method overrides
SameSite is a good control with a precise meaning. Most bypasses come from assuming it means more. This deep dive walks through the four gaps attackers actually use, then turns them into a checklist for your own app.
"Same site" is a bigger circle than you think
The browser decides what a site is with the Public Suffix List, a list of suffixes like .com,
.co.uk and github.io under which anyone can register names. The site is the scheme plus the label
just above the public suffix. So every subdomain of example-bank.com is the same site, and every
*.github.io page is its own site.
For a bank, the same site usually includes the main portal, a marketing site run by an agency, a status page on a SaaS provider, old campaign subdomains, and perhaps user-uploaded content. Any one of them becomes a launch pad if an attacker gets a foothold there.
Gap 1: a sibling subdomain
1. attacker takes over promo.example-bank.com
(dangling CNAME to a deleted cloud bucket, L08)
│
▼
2. hosts an auto-submitting form there:
POST https://portal.example-bank.com/payees
│
▼
3. victim visits promo.example-bank.com
browser: promo → portal is SAME-SITE
Cookie: __Host-sid=... (even SameSite=Strict!)
│
▼
4. portal sees a valid session → payee added
The __Host- prefix from L22 stops the sibling from planting cookies, but not from triggering
requests. Only a CSRF token, or a check that rejects Sec-Fetch-Site: same-site, stops step 4.
Gap 2: a GET that changes state
SameSite=Lax sends the cookie on top-level navigations with safe methods, because users expect to
stay logged in when they click a link to you. So a script on the attacker's page that sets
window.location to https://portal.example-bank.com/payees/remove?id=PE-3381 arrives with the
cookie. L09's rule (GET must be safe) is a CSRF defence.
The quiet way this happens in Spring is a mapping with no method:
@RequestMapping("/payees/remove") // accepts GET, POST, ...
void remove(@RequestParam String id) { ... }
@PostMapping("/payees/remove") // POST only
void remove(@RequestParam String id) { ... }
Gap 3: method overrides
Some frameworks let a parameter or header override the HTTP method, for HTML forms that can only
send GET and POST: _method=DELETE, or X-HTTP-Method-Override: PUT. If your framework honours an
override on a GET, the attacker sends a Lax-friendly GET that your app treats as a POST. Spring's
HiddenHttpMethodFilter only converts POST requests, which is the safe behaviour. Check what any
other framework, proxy or gateway route in your path does before trusting it.
Gap 4: Chrome's two-minute window
For cookies with no SameSite attribute, Chrome sends them on cross-site top-level POSTs for two
minutes after the cookie was set. Google called this a temporary compatibility measure for SSO
flows. An attacker who can force a fresh login (for example by sending the victim through an SSO
flow that silently reissues the cookie) gets a two-minute window. An explicit SameSite=Lax removes
it.
🧪 Worked example: auditing your own app
Run these against your own code and your local dev server:
# 1. Mappings that accept any method
grep -rn '@RequestMapping(' src/main/java | grep -v 'method'
# 2. Cookies without an explicit SameSite (local dev only)
curl -sk -D - -o /dev/null https://localhost:8443/login \
| grep -i '^set-cookie' | grep -vi 'samesite'
# 3. Method-override support switched on
grep -rni 'hiddenmethod\|method-override\|_method' src/ \
--include='*.java' --include='*.yml' --include='*.properties'
Then answer three questions on paper. Which subdomains of our registrable domain exist, and who
runs each one? Does any GET handler write to the database? Does our CSRF check reject same-site, or only
cross-site? If the honest answer to the last one is "we rely on SameSite", add a token or a Fetch
Metadata check.
🧠 Extra takeaways
- A site is scheme + registrable domain (per the Public Suffix List), so every subdomain is "same site".
- Sibling subdomains bypass even
SameSite=Strict; rejectSec-Fetch-Site: same-siteor use tokens. - Lax lets top-level GETs through, so state-changing GETs and method overrides are forgeable.
- Always set SameSite explicitly; the browser default differs between browsers and has a grace period.
CSRF and JSON APIs: content types, CORS preflights and bearer tokens
"Our API only accepts JSON, so CSRF can't happen" is half right. Whether it's right for your API depends on three things: what a cross-site page can send without asking, how strictly you parse the body, and how the caller authenticates.
What another site can send without permission
Browsers sort cross-origin requests into two kinds. A simple request (GET, HEAD or POST, with
Content-Type of application/x-www-form-urlencoded, multipart/form-data or text/plain, and no
custom headers) is sent straight away. Anything else first triggers a CORS preflight: an
OPTIONS request asking the server for permission (CORS in full is L37).
cats.example.net page api.example-bank.com
│ │
A. POST, Content-Type: text/plain │
│───────────────────────────────▶│ sent at once;
│ (simple request) │ side effect
│ │ happens; page
│◀──── response (page can't read)│ can't read reply
│ │
B. POST, Content-Type: application/json│
│── OPTIONS (preflight) ────────▶│
│◀── no Access-Control-Allow-* ──│
│ │
✗ browser never sends the POST │
Path A is the danger. The response is unreadable, but CSRF never needed to read it.
The text/plain trick
An HTML form with enctype="text/plain" writes name=value. Pick the name carefully and the body
looks like JSON:
<form method="POST" enctype="text/plain"
action="https://api.example-bank.com/v1/transfers">
<input name='{"to":"ACC-666","amount":5000,"pad":"'
value='"}'>
</form>
The body sent is {"to":"ACC-666","amount":5000,"pad":"="}, valid JSON with Content-Type: text/plain. (A script using fetch in no-cors mode can send any text/plain body, with no
trick needed.) Whether this works depends on your parser:
| Stack | Parses a text/plain body as JSON? |
|---|---|
Spring @RequestBody with Jackson |
No: 415 Unsupported Media Type |
Express express.json() defaults |
No: only application/json |
Express express.json({type: "*/*"}) |
Yes |
Flask request.get_json(force=True) |
Yes |
So: insist on Content-Type: application/json for JSON endpoints, and never "force" parsing. And
remember the cookie still has to arrive. With SameSite=Lax, a cross-site POST carries no cookie,
so this mostly bites apps on SameSite=None or attackers on a sibling subdomain.
Custom headers, and the CORS catch
Requiring a custom header (X-CSRF-Token, or even just X-Requested-With) forces a preflight,
so a cross-origin page can't send it unless your CORS policy says yes. That's why it's a real
defence. The catch is a CORS policy that reflects any Origin back with
Access-Control-Allow-Credentials: true. That tells the browser every site may send credentialed
custom-header requests and read the responses. It's worse than CSRF, and it's a common gateway
misconfiguration. If you run Kong, check the cors plugin's origins list for patterns that are too broad (L37).
Bearer tokens: why most APIs are immune
A browser never adds an Authorization: Bearer header by itself; your JavaScript does. No ambient
authority, no CSRF. That's why Spring guides say it's fine to disable CSRF for a stateless API
that only accepts bearer tokens. The protection disappears the moment the token moves into a cookie
(L25's storage table), the gateway authenticates with a session cookie, or the browser has cached
HTTP Basic credentials.
🧪 Worked example: test your own dev API
Against your local dev instance, with a session cookie copied from your own dev login:
# Should be 415: the endpoint must reject text/plain JSON
curl -s -o /dev/null -w '%{http_code}\n' \
-X POST http://localhost:8080/v1/transfers \
-H 'Content-Type: text/plain' \
--cookie 'SESSION=<your-dev-session>' \
-d '{"to":"ACC-TEST-2","amount":1}'
# Should be 403: cross-site labels must be rejected
curl -s -o /dev/null -w '%{http_code}\n' \
-X POST http://localhost:8080/v1/transfers \
-H 'Content-Type: application/json' \
-H 'Sec-Fetch-Site: cross-site' \
-H 'Origin: https://cats.example.net' \
--cookie 'SESSION=<your-dev-session>' \
-d '{"to":"ACC-TEST-2","amount":1}'
curl can set any header it likes, so these don't simulate an attacker. They check that your server makes the right decision when a real browser sends those labels. A 200 on either is a bug.
🧠 Extra takeaways
- Simple requests (form types, no custom headers) skip the CORS preflight; JSON and custom headers don't.
enctype="text/plain"can deliver a JSON-looking body; requireapplication/jsonand don't force-parse.- A required custom header is a CSRF defence only while CORS stays strict; a reflected origin with credentials undoes it.
- Bearer headers aren't ambient, so pure token APIs are safe, until a token lives in a cookie.
The quiz for this lesson and the Tests section need JavaScript.