TechEarl

Cross-Site Request Forgery (CSRF): The Complete 2026 Practitioner Guide

CSRF in 2026: how the classic GET/POST/JSON variants still work, what SameSite=Lax actually changed in 2020, and the defences that hold up: anti-CSRF tokens, Origin validation, Fetch Metadata, and custom headers.

Ishan Karunaratne⏱️ 18 min readUpdated
Share thisCopied
Cross-site request forgery variants, the SameSite cookie evolution, and the defence layers that actually hold

Cross-site request forgery is the third class on the web application security vulnerabilities map, sitting next to XSS and SQL injection under the same security hub. It is catalogued as CWE-352 and was its own slot in the OWASP Top 10 until 2017, after which Google's SameSite=Lax default (2020) and broader framework adoption knocked the easy cases out of the live web. The bug is still around in 2026 because the defaults only cover the lazy path: any cross-origin POST API that does not validate Origin, any state-changing GET, any endpoint that accepts Content-Type: text/plain and parses it as JSON, every one of these is still exploitable.

This article is the deep dive that sits next to the XSS guide and the SQL injection guide. I cover the variants (GET, POST, multipart, JSON), the SameSite cookie evolution and what the 2-minute Lax-by-default window in Chrome actually buys an attacker, then turn around and cover the defences (anti-CSRF tokens, Origin validation, Fetch Metadata, custom headers) in equal depth.

In short: what is cross-site request forgery?

CSRF is what happens when a victim's browser is tricked into issuing a state-changing request to an application the victim is already authenticated to, with the victim's cookies attached automatically by the browser. The attacker does not see the response and does not steal the session; the attacker rides the session to perform an action as the victim. Classical targets are "change email address", "transfer funds", "add SSH key", "promote user to admin", anywhere a single HTTP request causes a state change and the server's only proof of intent is the ambient session cookie. The fix is the same shape every time: the server must require an authenticator on every state-changing request that an attacker on another origin cannot produce, because the cookie alone is not proof of intent, it is only proof that the victim was once logged in.

What is CSRF, mechanically?

CSRF is a confused-deputy bug. The browser is the deputy: it holds the victim's session cookie for bank.example, and it will attach that cookie to any request to bank.example no matter which origin initiated the request. A page on attacker.example can cause the victim's browser to issue a request to bank.example (via an image tag, a form submit, a fetch, a redirect, an iframe), and the cookie rides along automatically. The server sees a request with a valid session cookie and, absent any other check, treats it as the user's intent.

That is the entire mechanism. Every variant below is a different answer to the question "how does the attacker convince the victim's browser to issue the request, and what does the server's request validation actually look at?".

The classic GET-based CSRF

A state-changing GET is the easiest CSRF target on the live web because the browser will issue a GET to any URL it sees, with cookies attached, no user interaction required.

html
<!-- on attacker.example -->
<img src="https://bank.example/transfer?to=mallory&amount=10000" width="0" height="0">

The victim visits attacker.example. The browser parses the <img> tag, issues a GET to bank.example/transfer?..., attaches the bank.example session cookie, and the bank's server processes the transfer. The image never loads (the response is not an image), but the request already happened and the state change is committed.

Why this still exists in 2026: any endpoint that performs a state change on GET is vulnerable in this exact way. Search the codebase for @GetMapping (Spring), app.get (Express), Route::get (Laravel), def get(self) (Django CBV) and check whether any of them mutate state. They should not, on principle (GET is supposed to be safe and idempotent per RFC 9110), but legacy admin tooling, "click this link to confirm" flows, and one-click unsubscribe handlers all reach for state-changing GETs.

Image-tag exfil is the lowest-friction delivery vector but it is not the only one. A <link rel="stylesheet" href="...">, an <iframe src="...">, a <script src="...">, a window.location redirect, even an <a href> the user clicks: all of them issue a cookie-bearing GET cross-origin and all of them are valid CSRF carriers.

POST-based CSRF: the hidden auto-submit form

For POST endpoints, the attacker uses a hidden form that auto-submits on page load:

html
<!-- on attacker.example -->
<form id="csrf" action="https://bank.example/transfer" method="POST">
    <input type="hidden" name="to" value="mallory">
    <input type="hidden" name="amount" value="10000">
</form>
<script>document.getElementById('csrf').submit();</script>

The browser submits the form to bank.example, attaches cookies, and the server processes the POST. The form's Content-Type defaults to application/x-www-form-urlencoded, which is one of the three "CORS-safe" content types the browser is allowed to send cross-origin without a preflight (the others being multipart/form-data and text/plain). No preflight, no opportunity for the browser to ask the server "is this cross-origin POST allowed?".

This is the canonical POST CSRF and the variant every framework's CSRF token middleware was designed to block. Without a token check, the form fires and the state change commits.

Multipart and Content-Type quirks

multipart/form-data is also CORS-safe and equally exploitable:

html
<form action="https://bank.example/upload" method="POST" enctype="multipart/form-data">
    <input type="hidden" name="filename" value="evil.txt">
    <input type="hidden" name="content" value="payload">
</form>

The interesting one is text/plain. It is CORS-safe, so the browser sends the request without a preflight, but the body can be arbitrary text. If the server accepts JSON on an endpoint, sniffs the body, and parses it whether or not the Content-Type says JSON, the attacker can smuggle a JSON-shaped body past CORS:

html
<form action="https://api.example/me" method="POST" enctype="text/plain">
    <input name='{"email":"mallory@evil.com","ignore":"' value='"}'>
</form>

The browser serialises this as {"email":"mallory@evil.com","ignore":"=" } with Content-Type: text/plain (the = between name and value is the standard form serialisation glitch you abuse here). A loose JSON parser accepts the body, the email gets changed, the CSRF works. This is why "we only accept JSON, so we are safe from CSRF" is wrong if the server does not enforce the Content-Type strictly.

The fix at the API layer is to require Content-Type: application/json for any JSON endpoint and to reject any other content type with a 415. application/json is not CORS-safe, so a cross-origin request that sets it triggers a preflight, and the preflight will fail unless the server explicitly allows the attacker's origin (which it should not).

The SameSite cookie attribute is the structural defence that changed the CSRF landscape between 2016 and 2020. It tells the browser when to attach a cookie on cross-site requests.

  • SameSite=None: cookie is attached on all cross-site requests. Must be paired with Secure. This is the pre-2020 default behaviour, made explicit.
  • SameSite=Lax: cookie is attached on top-level navigations (a link click, a typed URL, a window.location change) but not on cross-site subresource requests (image, iframe, fetch, form POST). This is the practical sweet spot and the modern default.
  • SameSite=Strict: cookie is attached only when the request originates from the same site, including same-site top-level navigations. Cross-site links into the application arrive without the cookie, so the user appears logged out even though they have a valid session.

Chrome made SameSite=Lax the default for unmarked cookies in February 2020 (Chrome 80). Firefox followed in 2021. Safari shipped its own ITP-based CSRF defence that has similar effect.

The critical detail Chrome shipped alongside the Lax default is the Lax-by-default exception for top-level POSTs: if a cookie was set in the last 2 minutes with no explicit SameSite attribute, Chrome will attach it on a top-level cross-site POST. This was a compatibility carve-out for SSO and OAuth redirect flows that rely on cross-site POST callbacks. Two minutes is short, but it is a window: a CSRF that triggers immediately after login (e.g., a malicious redirect to a "complete your profile" attacker page) can still ride a freshly-set Lax-by-default cookie. The mitigation is the same as before SameSite existed: do not rely on defaults, set SameSite=Lax (or Strict) explicitly on every cookie.

SameSite=Lax knocked out the easy mass CSRF vectors (hidden form auto-submit, image-tag GET) for cookies that carry it. It does not close every CSRF gap. Three places it still leaks:

  1. Same-site, cross-origin. Lax is same-site, not same-origin. If app.example.com and subdomain.example.com both share example.com as the eTLD+1, a cookie set on example.com with SameSite=Lax flows from one subdomain to the other. If the attacker controls any subdomain (a vulnerable forum, a marketing page, a third-party tenant on a shared apex), they can CSRF the main app.
  2. Top-level GETs still carry the cookie. State-changing GETs are still exploitable via image tags or window.location because Lax permits cookies on top-level navigations. The defence is to not have state-changing GETs in the first place.
  3. The 2-minute Lax-by-default window described above, for cookies without an explicit attribute.

JSON-body CSRF and the text/plain smuggle

Most modern APIs accept JSON. The naive assumption is that a JSON API is intrinsically CSRF-safe because a cross-origin fetch with Content-Type: application/json triggers a preflight, and the preflight will fail. That is true if the server enforces the content type. The exploit is the text/plain smuggle from earlier in this article:

javascript
// attacker page, no preflight because text/plain is CORS-safe
fetch('https://api.example/users/me', {
    method: 'POST',
    credentials: 'include',
    headers: { 'Content-Type': 'text/plain' },
    body: '{"email":"mallory@evil.com"}',
});

A server that calls JSON.parse(req.body) without checking the Content-Type header processes this happily. The fix is to validate the request content type at the server boundary, before parsing, and reject anything that is not application/json.

A second JSON-CSRF path is endpoints that accept either JSON or form-encoded for backward compatibility. The same payload can be reshaped as form data, sent with the CORS-safe application/x-www-form-urlencoded, and the server's form parser will accept it. Pick one content type per endpoint and enforce it.

Defences

Anti-CSRF tokens

The historical defence and still the strongest. The server generates a per-session (or per-request) random token, embeds it in every form, and requires it on every state-changing request. An attacker on another origin cannot read the token (same-origin policy) and cannot guess it (sufficient entropy), so they cannot forge a valid request. Three patterns:

  • Synchronizer token pattern. The server stores the token server-side (in the session), embeds it in form fields and AJAX headers, and compares the submitted token to the stored value. The original Rails / Django / Spring Security default.
  • Double-submit cookie. The server sets the token as a non-HttpOnly cookie and requires the client to echo it back in a request header or form field. The server compares the cookie value to the header/field value. No server-side storage, which is the appeal. Vulnerable to subdomain takeover (the attacker on a sibling subdomain can set the cookie value the server compares against) unless the token is bound to the session, e.g., via HMAC.
  • Encrypted token / signed token. The server emits a token that is an HMAC of the session ID and a server-side secret, requires it on every request, and verifies the signature on each one. Stateless variant of synchronizer.

Common mistakes I still see: per-application token (instead of per-session, so the same token works for every user); GET endpoints exempt from token check (so the GET-based variant still works); token sent in URL query string (it leaks via Referer); token not rotated after login (so a pre-login token captured via a separate page works post-login).

Origin and Referer validation

For any state-changing request, the server reads the Origin header (or falls back to Referer) and compares it against an allow-list of expected origins. A request from attacker.example carries Origin: https://attacker.example and is rejected.

The Origin header is set by the browser on every POST, PUT, DELETE, and on cross-origin GETs, and the page cannot forge or omit it. Referer is set on most navigations but can be stripped by privacy modes or Referrer-Policy, so check Origin first and fall back to Referer. Reject the request if neither matches.

This is a strong defence and it costs nothing in latency or session state. It pairs well with tokens (defence in depth) and is the only check that protects against the same-site, cross-origin gap in SameSite=Lax.

Custom request headers

The XMLHttpRequest and fetch specs require a CORS preflight for any request that carries a non-CORS-safe header. A custom header like X-Requested-With: XMLHttpRequest or X-Csrf: 1 triggers a preflight for cross-origin requests, and a cross-origin attacker cannot pass the preflight without the server's explicit cooperation. The server requires the header on every state-changing request and rejects anything without it.

This is the defence single-page apps lean on hardest. Every AJAX call from the SPA sets X-Requested-With, every state-changing endpoint requires it. A cross-origin attacker cannot set the header on a form submit (forms cannot set custom headers) and cannot set it on a fetch without tripping the preflight. The cost is one header per request.

Fetch Metadata

Sec-Fetch-Site, Sec-Fetch-Mode, Sec-Fetch-Dest, Sec-Fetch-User are browser-set headers introduced in 2020 that describe how the request was initiated. They are unforgeable from JavaScript. The server can use them to reject obviously-malicious requests:

code
Sec-Fetch-Site: cross-site
Sec-Fetch-Mode: navigate
Sec-Fetch-Dest: document
Sec-Fetch-User: ?1

A state-changing endpoint that requires Sec-Fetch-Site: same-origin rejects every cross-site request before it touches application logic. The web.dev guide at Protect your resources from web attacks with Fetch Metadata covers the full ruleset. Chrome, Edge, and Firefox send the headers; Safari shipped them in 16.4 (March 2023). Fall back to Origin validation for clients that do not send them.

SameSite, again

Every session cookie should carry SameSite=Lax explicitly. Anywhere Strict does not break the user experience (e.g., admin-only interfaces with no inbound cross-site links), use Strict. Do not rely on the browser default; set the attribute.

Token leakage

A working anti-CSRF token only helps if the attacker cannot read it. Three routes I still find tokens leaking through:

  • In the URL. A token in the query string of a state-changing GET ends up in the Referer of the next outbound request, in proxy logs, in CDN access logs, and in browser history. Tokens belong in headers or POST bodies, never in URLs.
  • Via XSS. An XSS payload inside the application can read the token straight from the DOM (document.querySelector('input[name=_csrf]').value) and replay it. CSRF tokens are not a defence against an attacker who already has script execution on the origin. Fix XSS first.
  • Via the cache. A response that embeds the token and is served with a permissive cache header (Cache-Control: public, s-maxage, no Vary) can be cached by a shared cache (CDN, reverse proxy) and served to the next user. The next user gets the previous user's token. Set Cache-Control: private, no-store on any response containing a CSRF token.

CSRF in SPAs

Single-page apps changed the surface. The old CSRF (hidden form auto-submit against a server-rendered endpoint) is largely closed by SameSite=Lax. What replaced it is the cross-origin POST API call: the SPA at app.example calls api.example with credentials, and the API has to defend against an attacker page at attacker.example that does the same.

The defence stack for SPA APIs in 2026:

  1. SameSite=Lax (or Strict) on session cookies. Knocks out the lazy cross-site CSRF.
  2. Origin header validation on every state-changing request. Cheap, exact, covers the same-site cross-origin gap.
  3. Custom header requirement (X-Requested-With or X-Csrf). Forces a preflight on any cross-origin POST, which the attacker cannot pass.
  4. Content-Type: application/json enforcement. Blocks the text/plain JSON smuggle.
  5. Fetch Metadata for browsers that send it. Defence in depth.
  6. Anti-CSRF tokens if the API is genuinely high-value (banking, account changes). Belt and braces.

For an API that serves third-party clients (mobile apps, server-to-server callers), the cookie-based session model is the wrong tool: switch to bearer tokens (Authorization: Bearer <jwt> or similar), which the browser does not attach automatically, and CSRF stops being a relevant class of bug. Bearer tokens introduce their own problems (XSS leaks the token, token revocation is hard), but they are not vulnerable to CSRF.

CSRF vs SSRF vs XSS: the boundary

These three classes are routinely confused in incident reports. The distinction matters because the fixes do not overlap:

  • CSRF: the victim's browser sends a forged request to a server the victim is authenticated to. The attacker rides the victim's session. Fix at the server: require an authenticator beyond the cookie.
  • XSS: the attacker's JavaScript runs on the victim's origin. The attacker can do anything the victim could do, including reading CSRF tokens and making authenticated requests. Fix at the rendering layer: escape output, strict CSP, sanitisation. See the XSS deep dive.
  • SSRF: the attacker tricks the server into making a request to an internal target (the cloud metadata service, an internal admin endpoint, a private database). The forgery is server-side, not client-side. Fix: validate outbound URLs server-side, network-level egress controls. Separate article.

Related framing attack to CSRF: clickjacking, where an attacker page iframes the application and tricks the victim into clicking UI inside the iframe to trigger state changes. SameSite cookies and X-Frame-Options / CSP frame-ancestors are the defences. See the clickjacking deep dive for the variant in detail.

Real-world incidents

A short tour. For per-incident details and CVSS where applicable, verify against the linked advisory before quoting.

  • Netflix CSRF (2006). Researcher Dave Ferguson demonstrated CSRF against Netflix's account-management endpoints, including forced additions to the DVD rental queue, account address changes, and shipping-list manipulation. The reports led to Netflix's first round of public-facing CSRF tokens on form endpoints. The case is widely cited as the moment CSRF entered mainstream consumer-product threat models.
  • Twitter "forced retweet" worm (2010). A combination of stored content and CSRF-flavoured forced action: a payload posted in a tweet caused viewers' clients to issue retweet requests automatically. Twitter classified it as XSS in its post-mortem, but the propagation vector (one user's session-bearing browser making a state change to Twitter on the user's behalf without intent) is the CSRF half of the chain. Worth studying as the canonical XSS-CSRF combination.
  • GitHub CSRF (2008). Egor Homakov's later GitHub mass-assignment issue (2012) overshadowed the earlier 2008 CSRF reports, but GitHub's early account-settings flows were vulnerable to the standard hidden-form pattern. GitHub now ships a strict anti-CSRF token on every state-changing endpoint and validates Origin on top of it.
  • WordPress plugin CSRF (ongoing). Search the WPScan database for "CSRF" and any popular plugin: new entries appear weekly. WordPress plugins are the highest-density CSRF surface on the live web because they often skip the nonce check (WordPress's built-in CSRF token) on admin-action endpoints. If you write WordPress plugins, every admin-side endpoint needs check_admin_referer() or wp_verify_nonce().

For the per-CVE details, pull the entry from nvd.nist.gov at time of writing; version-specific claims age fast.

Common defence mistakes I still see

  1. State-changing GET endpoints. Image-tag CSRF still works against these. RFC 9110 says GET is safe and idempotent; the codebase says otherwise. Move every state change to POST/PUT/DELETE and require a token.
  2. Relying on SameSite=Lax alone. It closes the easy cases but leaves same-site cross-origin gaps, the 2-minute Lax-by-default carve-out, and top-level GETs. Layer Origin validation on top.
  3. Per-application CSRF token. A token that is the same for every user is not a token, it is a checkbox. The token must be per-session and unguessable.
  4. CSRF token in the URL. Leaks via Referer, browser history, proxy logs. Tokens belong in headers or POST bodies.
  5. Skipping the token check on "AJAX-only" endpoints. A form-encoded POST is indistinguishable from a fetch POST on the wire if the attacker is shaping the request. Every state-changing endpoint requires the same checks.
  6. Trusting the Referer header alone. It is stripped by some privacy modes and Referrer-Policy: no-referrer. Check Origin first.
  7. No content-type enforcement on JSON endpoints. The text/plain smuggle works on any endpoint that parses arbitrary bodies as JSON.
  8. Treating CSRF as solved by the framework default. Frameworks ship the middleware but not the configuration. Verify the token is per-session, the cookie has SameSite=Lax, the Origin check is on, and no endpoint is exempt.

Where to go next

The CSRF cluster fans out from this hub into the tools listicle at best CSRF tools 2026, which covers the OWASP ZAP CSRF scanner, Burp Suite's CSRF PoC generator, the manual curl workflow, and the framework-specific test harnesses. For the cookie-stealing chain that pairs with CSRF in the wild, the XSS deep dive walks the cookie-theft route end to end and explains why an XSS-clean origin is a precondition for CSRF tokens to be meaningful. For the related framing attack that uses iframes to forge clicks rather than requests, the clickjacking deep dive covers X-Frame-Options, frame-ancestors, and the SameSite interaction.

For the wider map, back up to the web application security vulnerabilities taxonomy.

Sources

Authoritative references this article was fact-checked against.

TagsCSRFCross-Site Request ForgerySecurityWeb SecurityOWASPSameSiteCookies

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts