01 //Introduction
Cross-Site Request Forgery (CSRF, sometimes XSRF or "session riding") is a client-side trust confusion bug. A user is authenticated to your application; a different origin convinces the user's browser to issue a state-changing request to your application; the browser attaches the session cookie automatically; your server sees a properly-authenticated request and acts on it. The attacker never touches the session cookie, never reads any response, and never needs the victim to do more than open a page. The flaw is twenty-five years old, has its own dedicated CWE-352, and still appears in fresh advisories every quarter, because the underlying browser behaviour (send cookies on cross-origin requests by default) was only partially fixed by the SameSite cookie rollout in 2020.
The bug lives at the boundary between two browser features that, individually, are correct. Cookies are ambient authority: once set, they ride along on every request to the matching origin, regardless of which page issued the request. Forms and fetch() calls are allowed to target cross-origin URLs. Combined, the browser will happily issue an authenticated request to your application from any other tab the user has open. Modern defenses, SameSite cookies, CSRF tokens, Origin checks, all exist to break this default trust and force the server to verify that the request was intended by the user.
When Chrome defaulted cookies to SameSite=Lax in 2020, much of the security community declared CSRF dead. It is not. Lax cookies are sent on top-level cross-site GET navigations, so any state-changing GET endpoint is still exposed. Lax is also bypassed by same-site subdomain takeovers, by browsers without the default (older Safari, embedded webviews, many mobile browsers in restricted markets), and by any flow where the developer set SameSite=None to support third-party embedding. Tokens and Origin checks remain mandatory for real defense; SameSite is a useful backstop, not a replacement.
OWASP catalogues CSRF in the CSRF Prevention Cheat Sheet, and the bug class dropped out of the OWASP Top 10 between 2017 and 2021 precisely because framework defaults improved, not because the bug disappeared. The defensive recipe is identical across languages: protect every state-changing endpoint with a token tied to the session, verify the request origin, set cookies to SameSite=Lax at minimum, and never allow GET to mutate state. This module walks through the attack surface, the canonical payloads, the per-framework fix, and the modern subtleties (BREACH on tokens, JSON CSRF, login CSRF) that still bite teams in 2026.
A banking app sets its session cookie with the default SameSite=Lax. The /transfer endpoint accepts POST with form data and no CSRF token, relying on SameSite to block cross-origin POSTs. Why is this still vulnerable?
02 //The Attack Flow
Every CSRF exploit follows the same four-stage flow: the victim authenticates to the target application (a session cookie is now stored in their browser), the victim visits or is redirected to an attacker-controlled page on a different origin, that page causes the browser to issue a state-changing request to the target (via a hidden form, an image tag, a fetch() call, or a top-level navigation), and the browser, following its default cookie behaviour, attaches the session cookie. The server sees an authenticated request and performs the action. The attacker never reads the response; they do not need to. The damage is the side effect.
bank.appevil.tld renders a hidden form or fetch() to bank.appPOST /transfer succeeds; the user never clicked itCSRF is not an authentication bug; the user is correctly authenticated. It is an authorisation bug at the request boundary: the server cannot tell whether the state-changing request was intended by the user or smuggled in by another origin that the browser was tricked into loading. Every CSRF defense, tokens, SameSite, Origin checks, is a way to prove intent before the cookie is honoured.
The shape of a realistic exploit depends on the request the attacker needs to issue. HTML forms can submit POST with application/x-www-form-urlencoded, multipart/form-data, or text/plain bodies cross-origin without a preflight, so any endpoint that accepts those content types is reachable. fetch() and XMLHttpRequest can issue any method with any body, but cross-origin requests with custom headers or JSON content types trigger a CORS preflight, which the attacker cannot satisfy unless your CORS policy is misconfigured. img, script, link, and iframe tags can issue cross-origin GETs.
CSRF Attack Surfaces in Real Applications
| Endpoint shape | Why it is exposed |
|---|---|
| POST with form-urlencoded body | Native HTML form, no preflight, cookies attached |
| GET that changes state | Any tag (img/link/iframe) triggers it; SameSite=Lax allows top-level navigation |
| POST with text/plain body | No preflight; JSON-like payloads can be smuggled if the server parses lenient bodies |
| POST with multipart/form-data | File-upload endpoints reachable from a form with enctype=multipart |
| Endpoints behind method-override (?_method=DELETE) | Attacker uses POST with the override param; bypasses "POST-only" assumption |
| CORS-with-credentials misconfiguration | Server reflects Origin into Access-Control-Allow-Origin with Allow-Credentials: true, opening every endpoint |
| Login endpoints (login CSRF) | Attacker forces victim to log in as the attacker, mixing identities in the session |
| Logout endpoints | Forced logout used as nuisance or to mask a phishing attempt |
| JSON APIs without preflight enforcement | If the server accepts text/plain or form-urlencoded as JSON, the preflight protection is moot |
A simple <img src="https://app.tld/account/delete"> on any page the victim opens triggers a cross-origin GET with cookies attached. If /account/delete mutates state on GET, the account is gone the moment the victim views the page. Every "no body, no harm" assumption breaks on GET endpoints that perform writes. The OWASP rule, GET must be safe and idempotent, is not a style preference; it is a CSRF mitigation.
1<!-- Hosted at https://evil.tld/promo.html
2 The victim is signed in to bank.app in another tab.
3 They open this page from a phishing email.
4 The form auto-submits with the session cookie attached. -->
5<!doctype html>
6<html>
7 <body>
8 <h1>You won a prize!</h1>
9 <form id="x" action="https://bank.app/transfer" method="POST">
10 <input type="hidden" name="to" value="attacker-account-1234" />
11 <input type="hidden" name="amount" value="5000" />
12 </form>
13 <script>document.getElementById('x').submit();</script>
14 </body>
15</html>
16
17<!-- Variant 1, image tag for a GET endpoint that mutates state -->
18<img src="https://bank.app/account/close?confirm=1" width="0" height="0" />
19
20<!-- Variant 2, JSON CSRF where the server accepts text/plain
21 This is a CORS-preflight-free request, no Content-Type negotiation. -->
22<form action="https://bank.app/api/transfer" method="POST" enctype="text/plain">
23 <input name='{"to":"attacker","amount":5000,"x":"' value='"}' />
24</form>A team argues their JSON API is safe from CSRF because browsers send a CORS preflight for application/json requests, which their server rejects. A pentester demonstrates a successful CSRF anyway. What did the team miss?
03 //Payload Anatomy & Token Bypasses
The attacker's payload is constrained by what the browser will let cross-origin code do without triggering a CORS preflight. The "simple request" set, GET, HEAD, and POST with form-urlencoded, multipart, or text/plain bodies, is reachable from a plain HTML form on any page. Anything outside that set (custom methods, custom headers, JSON content type) preflights, and a correctly-configured CORS policy refuses the preflight. The defender's mistake is usually to add a token check, then forget one of the bypass paths: an alternate verb, a sibling endpoint, a method-override middleware, or a same-origin XSS that reads the token directly.
SameSite=LaxorStricton the session cookie- Synchronizer token or double-submit token on every state-changing endpoint
Origin/Sec-Fetch-Sitevalidation on the server- Reject
POST,PUT,PATCH,DELETEwithout a CSRF check
- Custom request header (
X-Requested-With) for JSON APIs - Re-authentication or step-up for high-value actions
- Short session lifetime + idle timeout on sensitive routes
__Host-cookie prefix to lock the path and scope- CORS allow-list locked to your own origins, with credentials guarded
SameSite alone is not enough. Lax does not cover top-level GET-triggered side effects, browsers older than 2020 ignore it, and a same-site subdomain XSS makes it useless. The layered defense is: SameSite plus a token plus an Origin check. If you remove any one of the three, the next regression in another layer becomes exploitable.
Payload Catalogue, What an Attacker Actually Sends
| Payload pattern | Effect | Mitigation that defeats it |
|---|---|---|
| Cross-origin POST with session cookie attached | Synchronizer or double-submit token; Origin check | |
| Cross-origin GET with cookie; works against any state-changing GET | Never mutate state on GET; require POST and a token | |
| JSON CSRF without triggering a CORS preflight | Strict Content-Type check on the server (require application/json); reject text/plain | |
| POST /api/x?_method=DELETE | Method-override middleware turns POST into DELETE, bypassing "DELETE requires preflight" | Apply token + Origin check before method-override middleware, or remove the middleware |
| Login form posted from evil.tld (login CSRF) | Victim is silently logged into attacker's account; subsequent victim actions are attributed to attacker | CSRF-protect the login endpoint too; pre-session token bound to the login form |
| fetch("/api/x", { credentials: "include", mode: "no-cors" }) | Fire-and-forget cross-origin write; response is opaque but the side effect lands | Token + Origin check on the endpoint; never rely on attacker being unable to read the response |
| Subdomain XSS reads token from main app | XSS on staging.app.tld reads CSRF token via shared cookie, then submits to app.tld | __Host- cookie prefix; cookie path/domain scoping; treat subdomains as untrusted |
| BREACH-style compression oracle leaks token | gzip + reflected token + attacker-controlled query parameter leaks the token byte by byte | Rotate token per request, or disable compression on responses that include the token |
1<!-- 1. Classic auto-submitting form, the canonical 2002 attack. -->
2<form id="f" action="https://app.tld/email/change" method="POST">
3 <input name="email" value="attacker@evil.tld">
4</form>
5<script>f.submit()</script>
6
7<!-- 2. JSON-via-text-plain bypass, defeats lenient JSON parsers. -->
8<form action="https://app.tld/api/role" method="POST" enctype="text/plain">
9 <input name='{"role":"admin","_pad":"' value='"}'>
10</form>
11<!-- The body becomes: {"role":"admin","_pad":"="} (valid JSON) -->
12
13<!-- 3. Method-override bypass, defeats DELETE-requires-preflight assumption. -->
14<form action="https://app.tld/account?_method=DELETE" method="POST">
15</form>Login endpoints are often left unprotected on the assumption that "the user is not logged in yet, so there is no session to ride". This misses the attack. The attacker forces the victim's browser to log in as the attacker. The victim then thinks they are using their own account and adds payment details, uploads files, sends messages, all of which land in the attacker's account. The fix is the same: bind a pre-session token to the login form and verify it on submit. Frameworks like Django and Rails do this by default; many hand-rolled auth implementations do not.
A reviewer adds CSRF tokens to all POST endpoints. A teammate notes that the same endpoints also accept PUT and DELETE via fetch from the SPA, and the token check is only applied in the POST handler. What is the correct fix?
04 //Mitigation Patterns
There are three correct defenses, layered. Use a synchronizer token (or its stateless cousin, the signed double-submit token) on every state-changing endpoint; set session cookies to SameSite=Lax at minimum with the __Host- prefix; and validate the Origin header (falling back to Referer) on every state-changing request. None of these on its own closes the class. SameSite leaks via subdomain XSS and embedded browsers. Tokens leak via BREACH or are forgotten on new endpoints. Origin checks fail on requests where the header is missing. Together, any two surviving any one regression keep the attack out.
Mitigation Options at a Glance
| Strategy | How | When to use |
|---|---|---|
| Synchronizer Token Pattern | Server stores a per-session random token; client sends it in a hidden form field or X-CSRF-Token header; server compares constant-time | Default for server-rendered apps; the gold-standard defense |
| Signed double-submit cookie | Server sets a token in a cookie AND requires it in a header; tokens are HMAC-signed and bound to the session ID | Stateless backends, SPAs, edge functions; avoids server-side session storage |
| SameSite cookie attribute | Set session cookie with SameSite=Lax (default) or Strict; combined with __Host- prefix | Always; backstop defense even when tokens are present |
| Origin / Sec-Fetch-Site header check | Server rejects state-changing requests whose Origin (or Sec-Fetch-Site) does not match the application origin | Always; cheap, robust, browser-enforced |
| Custom request header (X-Requested-With) | Server requires a custom header that cross-origin simple requests cannot set without a preflight | JSON APIs; a useful belt-and-braces layer, NOT a sole defense |
| Re-authentication for sensitive actions | Require password / MFA / step-up for high-value actions (transfers, deletes, permission grants) | Anywhere the blast radius of a single CSRF would be severe |
1// lib/csrf.ts, signed double-submit pattern.
2//
3// Strategy: derive a per-session HMAC token, send it in a non-HttpOnly
4// cookie AND require it in the X-CSRF-Token header on state-changing
5// requests. Compare constant-time. The HMAC binds the token to the
6// session so the attacker cannot mint or replay tokens.
7
8import crypto from 'crypto';
9import type { Request, Response, NextFunction } from 'express';
10
11const SECRET = process.env.CSRF_SECRET!; // 32+ bytes from a secure store
12const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
13const TRUSTED_ORIGINS = new Set([
14 'https://app.tld',
15 'https://www.app.tld',
16]);
17
18function sign(sessionId: string): string {
19 const nonce = crypto.randomBytes(16).toString('hex');
20 const mac = crypto
21 .createHmac('sha256', SECRET)
22 .update(`${sessionId}.${nonce}`)
23 .digest('hex');
24 return `${nonce}.${mac}`;
25}
26
27function verify(sessionId: string, token: string): boolean {
28 const [nonce, mac] = token.split('.');
29 if (!nonce || !mac) return false;
30 const expected = crypto
31 .createHmac('sha256', SECRET)
32 .update(`${sessionId}.${nonce}`)
33 .digest('hex');
34 // constant-time compare, prevents timing leaks
35 const a = Buffer.from(mac, 'hex');
36 const b = Buffer.from(expected, 'hex');
37 return a.length === b.length && crypto.timingSafeEqual(a, b);
38}
39
40export function csrfMiddleware(req: Request, res: Response, next: NextFunction) {
41 const sessionId = req.session?.id;
42 if (!sessionId) return next(); // unauthenticated; no session to ride
43
44 // Issue a fresh token on every request, set as readable cookie.
45 // The cookie is NOT HttpOnly because the SPA needs to read it.
46 // It IS SameSite=Strict and __Host- prefixed to lock the scope.
47 if (!req.cookies['__Host-csrf']) {
48 res.cookie('__Host-csrf', sign(sessionId), {
49 secure: true,
50 sameSite: 'strict',
51 path: '/',
52 });
53 }
54
55 if (SAFE_METHODS.has(req.method)) return next();
56
57 // 1. Origin check, cheap and robust
58 const origin = req.get('origin') ?? req.get('referer');
59 if (!origin || !TRUSTED_ORIGINS.has(new URL(origin).origin)) {
60 return res.status(403).json({ error: 'origin mismatch' });
61 }
62
63 // 2. Token check
64 const headerToken = req.get('x-csrf-token');
65 const cookieToken = req.cookies['__Host-csrf'];
66 if (!headerToken || headerToken !== cookieToken || !verify(sessionId, headerToken)) {
67 return res.status(403).json({ error: 'csrf token invalid' });
68 }
69
70 next();
71}
72
73// Mount as global middleware; never per-route.
74// app.use(csrfMiddleware);Token comparison with === or strcmp leaks the matched-prefix length via timing. Modern CPUs make the leak small but real, and CSRF tokens are short-lived enough that even a few bits per attempt matter. Use crypto.timingSafeEqual in Node, hmac.compare_digest in Python, MessageDigest.isEqual in Java, hash_equals in PHP, and subtle.ConstantTimeCompare in Go. Same rule as for password and HMAC verification.
1# settings.py, the secure default. Django ships CSRF protection
2# enabled; the work is making sure you do not opt out.
3
4MIDDLEWARE = [
5 "django.middleware.security.SecurityMiddleware",
6 "django.contrib.sessions.middleware.SessionMiddleware",
7 "django.middleware.common.CommonMiddleware",
8 "django.middleware.csrf.CsrfViewMiddleware", # MUST be present
9 "django.contrib.auth.middleware.AuthenticationMiddleware",
10]
11
12# Cookie hardening, the __Host- prefix requires Secure + Path=/ + no Domain.
13SESSION_COOKIE_SECURE = True
14SESSION_COOKIE_HTTPONLY = True
15SESSION_COOKIE_SAMESITE = "Lax"
16CSRF_COOKIE_SECURE = True
17CSRF_COOKIE_SAMESITE = "Lax"
18CSRF_COOKIE_NAME = "__Host-csrftoken" # locks path / forbids Domain
19
20# Trust ONLY your own origins. Never wildcard.
21CSRF_TRUSTED_ORIGINS = [
22 "https://app.tld",
23 "https://www.app.tld",
24]
25
26# Templates: every form must include {% csrf_token %}
27# Class-based views are protected automatically.
28# DO NOT decorate views with @csrf_exempt unless you have an alternative defense.
29
30# For DRF (Django REST Framework) JSON APIs:
31# Use SessionAuthentication (CSRF-protected) for browser clients.
32# Use TokenAuthentication / JWT (Authorization header) for non-browser clients,
33# which are not subject to CSRF because no cookie is attached.
34
35from rest_framework.views import APIView
36from rest_framework.authentication import SessionAuthentication
37
38class TransferView(APIView):
39 authentication_classes = [SessionAuthentication] # CSRF enforced
40 def post(self, request):
41 # request.session is authenticated AND the CSRF token has been verified
42 ...1import org.springframework.context.annotation.Bean;
2import org.springframework.context.annotation.Configuration;
3import org.springframework.security.config.annotation.web.builders.HttpSecurity;
4import org.springframework.security.web.SecurityFilterChain;
5import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
6import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
7import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler;
8
9@Configuration
10public class SecurityConfig {
11
12 @Bean
13 public SecurityFilterChain chain(HttpSecurity http) throws Exception {
14 // XOR handler defeats BREACH-style compression oracle leaks on the
15 // token by re-masking on every render.
16 XorCsrfTokenRequestAttributeHandler xor = new XorCsrfTokenRequestAttributeHandler();
17
18 http.csrf(csrf -> csrf
19 // Token in a cookie, readable by the SPA; double-submit pattern.
20 .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
21 .csrfTokenRequestHandler(xor)
22 // Do NOT call .disable() unless you have an alternative defense.
23 );
24 return http.build();
25 }
26}
27
28// Cookie hardening (in application.yml)
29// server.servlet.session.cookie.secure=true
30// server.servlet.session.cookie.http-only=true
31// server.servlet.session.cookie.same-site=lax1// Program.cs, the secure default. AddAntiforgery is wired in by
2// AddControllersWithViews / AddRazorPages; the work is the cookie hardening.
3
4builder.Services.AddAntiforgery(options =>
5{
6 options.Cookie.Name = "__Host-X-CSRF";
7 options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
8 options.Cookie.SameSite = SameSiteMode.Lax;
9 options.Cookie.HttpOnly = true;
10 options.HeaderName = "X-CSRF-Token"; // SPA reads cookie, sets header
11});
12
13builder.Services.ConfigureApplicationCookie(options =>
14{
15 options.Cookie.Name = "__Host-Auth";
16 options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
17 options.Cookie.SameSite = SameSiteMode.Lax;
18 options.Cookie.HttpOnly = true;
19});
20
21var app = builder.Build();
22
23// Global anti-forgery filter, enforced on ALL non-safe methods.
24app.UseAntiforgery();
25
26// Controllers
27[ApiController]
28[Route("api/[controller]")]
29public class TransferController : ControllerBase
30{
31 // [ValidateAntiForgeryToken] is implicit when UseAntiforgery is mounted;
32 // explicit per-action only if you want to override.
33 [HttpPost]
34 public IActionResult Post([FromBody] TransferRequest req) => Ok();
35}
36
37// NEVER apply [IgnoreAntiforgeryToken] without an alternative defense.The pattern that survives in production is the same as for SQL prepared statements and HTML sanitisation: one central enforcement point, mounted globally, default-deny, with explicit opt-out that code review notices. Per-handler decorators are forgotten on the fourteenth new endpoint. A global middleware with an allow-list for the rare safe-but-unprotected routes is the only model that resists drift, every new endpoint inherits the defense automatically and the unsafe path is loud.
A team migrates from server-rendered pages to a SPA that calls a JSON API. They drop CSRF tokens, arguing 'our API uses Bearer tokens from localStorage, not cookies, so CSRF does not apply'. Are they right?
05 //Conclusion
Cross-Site Request Forgery is a textbook case of ambient authority used against the user who holds it. The browser is correct; cookies are correct; HTML forms are correct. The vulnerability lives in the moment the application accepts a state-changing request and trusts the cookie without verifying the request was intended. Fix it the same way every adjacent class is fixed: one global middleware, applied at every sink, layered with browser-enforced controls (SameSite, Sec-Fetch-Site, __Host-) so that no single regression becomes an incident.
Mount a global CSRF middleware on every state-changing method (POST, PUT, PATCH, DELETE), no exceptions · Use a synchronizer token or signed double-submit token; compare constant-time · Set session and CSRF cookies with SameSite=Lax (or Strict) and the __Host- prefix · Validate Origin / Referer on every state-changing request; allow-list explicit origins · Treat Sec-Fetch-Site as a primary signal where available · Never mutate state on GET; enforce method discipline · Require strict Content-Type on JSON endpoints; reject text/plain · Configure CORS with explicit origins, never reflection-with-credentials · Protect the login endpoint against login CSRF · Mask or rotate tokens per render to defeat BREACH · Require re-authentication for high-value actions · Add a Semgrep / CodeQL rule that flags every CSRF opt-out.
When you see a new state-changing endpoint in a diff, the first question is not "does the handler do the right thing", it is "does the global CSRF middleware reach this route, and is there any way to invoke this endpoint that bypasses it?". If the answer involves a method-override, a lenient body parser, a per-route exemption, or a missing Origin check, the bug class is alive. The fix is one middleware and one cookie attribute away. Pair this module with the CORS Misconfiguration, Session Fixation & Management, and Clickjacking & UI Redressing guides for the adjacent classes where the browser's default trust model is the attack surface.