CyberNugget

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:

  1. An action worth forcing: a transfer, an email change, a new payee.
  2. Ambient authentication: the server identifies the user only by something the browser sends on its own.
  3. 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 SameSite explicitly 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 Referer headers.
  • 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, or none (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/Origin isn'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.

Deep dive 1

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; reject Sec-Fetch-Site: same-site or 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.
Deep dive 2

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; require application/json and 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.