CSRF and SameSite Cookies
The Attack That Uses Your Identity Against You
Imagine you're logged into your bank. In another tab, you visit a meme site. That meme site silently submits a form to your bank's transfer endpoint. Your browser helpfully attaches your session cookie — because that's what browsers do with cookies. The bank sees a legitimate, authenticated request. Money moves.
That's Cross-Site Request Forgery. The attacker never steals your credentials. They don't need to. They just trick your browser into making a request on their behalf, riding on your existing session.
Think of CSRF like someone forging a letter with your return address. They don't need to break into your house or steal your stationery. They just need to know where the post office is (the target URL) and what to write (the request format). The post office (browser) sees your return address (cookie) and delivers the letter as if you sent it.
How a CSRF Attack Works
Every CSRF attack follows the same pattern:
- The victim is authenticated on
bank.com(has a valid session cookie) - The victim visits
evil.com(or any compromised page) evil.comcontains HTML/JS that triggers a request tobank.com- The browser automatically attaches
bank.com's cookies to that request bank.comprocesses the request as if the victim initiated it
Here's what the malicious page might look like:
<!-- Hidden form that auto-submits on page load -->
<form action="https://bank.com/transfer" method="POST" id="csrf-form">
<input type="hidden" name="to" value="attacker-account" />
<input type="hidden" name="amount" value="10000" />
</form>
<script>document.getElementById('csrf-form').submit();</script>
The victim sees nothing. The form is hidden. The submit happens instantly. By the time they notice, the request has already been processed.
Why GET requests are especially dangerous
If a state-changing action uses GET (which it shouldn't, but many APIs do), the attack gets even simpler:
<!-- Just an image tag is enough -->
<img src="https://bank.com/transfer?to=attacker&amount=10000" />
No JavaScript needed. No form submission. A simple img tag fires a GET request, and the browser sends cookies along. This is why state-changing operations must never use GET — it's not just a REST convention, it's a security boundary.
The Root Cause: Ambient Authority
CSRF exists because of ambient authority — the browser automatically attaches credentials (cookies) to requests based on the destination domain, not the origin of the request. The server has no built-in way to distinguish between:
- A request the user intentionally made by clicking a button on
bank.com - A request that
evil.comtriggered behind the user's back
Both requests arrive at bank.com with identical cookies. The HTTP request itself carries no reliable indicator of the user's intent.
Why CORS does not prevent CSRF
A common misconception is that CORS protects against CSRF. It does not. CORS controls whether the response is readable by the requesting origin — it does not prevent the request from being sent.
For simple requests (form submissions with application/x-www-form-urlencoded, multipart/form-data, or text/plain), the browser sends the request immediately and only checks CORS headers on the response. The damage is already done by the time CORS blocks the response.
CORS only blocks preflight-required requests (custom headers, application/json content type, etc.) — but attackers can often craft attacks using simple request types to avoid preflight entirely.
Defense 1: SameSite Cookie Attribute
The SameSite cookie attribute is the modern, browser-level defense against CSRF. It tells the browser when to include the cookie in cross-site requests.
Set-Cookie: session=abc123; SameSite=Lax; Secure; HttpOnly
The Three Modes
SameSite=Strict — The cookie is never sent on cross-site requests. Period.
If you're on evil.com and click a link to bank.com, the browser will not include the session cookie. You'd arrive at bank.com as if you're logged out — even though you have a valid session.
SameSite=Lax — The cookie is sent on cross-site top-level navigations using safe methods (GET), but not on cross-site subresource requests (images, iframes, POSTs).
This is the sweet spot. Clicking a link from an email to bank.com works (you arrive logged in), but a hidden form POST from evil.com does not include your cookie.
SameSite=None — The cookie is always sent, even cross-site. This opts out of SameSite protection entirely. Requires the Secure flag (HTTPS only).
Used when you genuinely need cross-site cookie sending — embedded widgets, federated login flows, third-party integrations. But it leaves you wide open to CSRF unless you have other defenses.
Why Lax Is the Modern Default
Since Chrome 80 (February 2020), cookies without an explicit SameSite attribute are treated as SameSite=Lax. Firefox and Edge followed. This single change neutralized the majority of CSRF attacks on the web overnight.
Before this change, cookies without SameSite were sent on every request to the matching domain — which is the behavior that made CSRF so devastating for decades. The shift to Lax-by-default means that even applications that never thought about CSRF got baseline protection.
Defense 2: CSRF Tokens (Synchronizer Token Pattern)
SameSite cookies are powerful, but you shouldn't rely on them alone. CSRF tokens are the tried-and-true server-side defense.
The idea is simple: include a secret, unpredictable value in every state-changing request that an attacker cannot know or guess.
How It Works
- When the server renders a form (or sends a page), it generates a random token tied to the user's session
- The token is embedded in the HTML (in a hidden form field or meta tag)
- When the form is submitted, the token is sent along with the request
- The server validates that the token matches the one it issued for that session
<!-- Server renders this hidden field in the form -->
<form action="/transfer" method="POST">
<input type="hidden" name="_csrf" value="a1b2c3d4e5f6..." />
<input name="to" />
<input name="amount" />
<button type="submit">Transfer</button>
</form>
The attacker on evil.com can craft a form that POSTs to your server, but they cannot read the CSRF token from your page (same-origin policy prevents it). Without the correct token, the server rejects the request.
For Single-Page Applications
SPAs don't render server-side forms. Instead, the token is typically delivered via:
- A meta tag in the initial HTML
- A dedicated endpoint that returns the token
- A cookie (which leads us to the double-submit pattern)
The SPA reads the token and includes it as a custom header on every mutating request:
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
},
body: JSON.stringify({ to: 'account', amount: 100 }),
});
Using a custom header like X-CSRF-Token has a bonus effect: it triggers a CORS preflight. Cross-origin requests with custom headers require a preflight OPTIONS check, and if your server doesn't respond with the appropriate Access-Control-Allow-Headers, the actual request never fires. This adds a layer of defense beyond the token itself.
Defense 3: Double-Submit Cookie Pattern
This is a variation of CSRF tokens that's popular because it's stateless — the server doesn't need to store tokens in a session.
How It Works
- The server sets a random value in both a cookie and the response body (or a header)
- The client reads the value from the cookie (via JavaScript) and sends it as a request header or form field
- The server compares the cookie value with the header/field value
Set-Cookie: csrf=random-token-value; SameSite=Lax; Secure
const csrfCookie = document.cookie
.split('; ')
.find(c => c.startsWith('csrf='))
?.split('=')[1];
fetch('/api/transfer', {
method: 'POST',
headers: {
'X-CSRF-Token': csrfCookie,
},
credentials: 'include',
body: JSON.stringify({ to: 'account', amount: 100 }),
});
Why does this work? The attacker can trigger a request that includes the CSRF cookie (browsers attach cookies automatically), but they cannot read the cookie's value from another origin (same-origin policy). Without reading the value, they can't set the matching header.
The double-submit cookie is weaker than the synchronizer token pattern if your site has subdomain vulnerabilities. An attacker who controls evil.bank.com can set cookies for bank.com (cookie scoping rules allow subdomains to set parent domain cookies). They could set their own csrf cookie value and include the matching header, bypassing the defense. Mitigate this by signing the CSRF cookie with a server-side secret (HMAC).
Defense 4: Origin and Referer Header Checking
Every request includes headers that indicate where it came from. The server can check these to reject cross-origin requests.
Origin header — Present on all POST requests and CORS requests. Contains the scheme and domain of the requesting page (e.g., https://evil.com). Cannot be spoofed by JavaScript.
Referer header — Contains the full URL (or at least the origin) of the page that initiated the request. More widely available but can be stripped by privacy settings or Referrer-Policy.
function validateOrigin(req) {
const origin = req.headers['origin'];
const referer = req.headers['referer'];
const allowedOrigins = ['https://bank.com'];
if (origin) {
return allowedOrigins.includes(origin);
}
if (referer) {
const refererOrigin = new URL(referer).origin;
return allowedOrigins.includes(refererOrigin);
}
// No Origin or Referer — reject by default
return false;
}
Limitations
- The
Originheader is sometimes absent on same-origin requests in older browsers - The
Refererheader can be suppressed byReferrer-Policy: no-referrer - Privacy-focused browsers may strip these headers
- Should be used as a supplementary defense, not the only one
Why SameSite Alone Isn't Enough
SameSite=Lax is a massive improvement, but it's not a complete CSRF solution:
1. GET-based state changes are still vulnerable. Lax sends cookies on top-level GET navigations. If your app changes state via GET requests (e.g., /delete-account?confirm=true), SameSite=Lax doesn't help.
2. Old browsers don't support SameSite. While support is now above 95%, if you need to support legacy browsers, you can't rely on SameSite alone.
3. Subdomain attacks bypass SameSite. SameSite uses the registrable domain (eTLD+1) for its "same-site" check. So evil.bank.com is considered same-site with app.bank.com. If an attacker controls any subdomain, they can bypass SameSite.
4. SameSite=None is required for legitimate use cases. Some cookies (SSO, embedded widgets) must be SameSite=None, which opts out of protection entirely.
5. Browser implementation varies. Not all browsers handle edge cases identically. Defense in depth is always safer than trusting a single mechanism.
- 1Never use GET for state-changing operations — Lax cookies are sent on cross-site GET navigations
- 2Layer defenses: SameSite + CSRF tokens + Origin checking. Never rely on a single mechanism
- 3Set SameSite=Strict for cookies that never need cross-site access (admin panels, internal tools)
- 4Always pair SameSite=None with Secure flag — browsers reject None without Secure
- 5CSRF tokens must be cryptographically random, tied to the session, and validated server-side on every mutating request
- 6The double-submit cookie must NOT be HttpOnly — JavaScript needs to read it to send it as a header
Putting It All Together: Defense in Depth
No single defense is bulletproof. The right approach layers multiple mechanisms:
| Defense | Protects Against | Weakness |
|---|---|---|
| SameSite=Lax | Most cross-site POST/iframe attacks | GET state changes, subdomain attacks |
| CSRF tokens | Forged requests from any origin | Requires server-side session state |
| Double-submit cookie | Forged requests (stateless) | Subdomain cookie injection |
| Origin/Referer check | Cross-origin requests | Can be absent, privacy-stripped |
| Custom request headers | Simple request attacks (triggers preflight) | Only works for AJAX, not form submissions |
The production-grade approach:
- SameSite=Lax on all session cookies (you get this for free in modern browsers)
- CSRF tokens on all state-changing endpoints (synchronizer pattern for server-rendered apps, double-submit for SPAs)
- Origin header validation as a supplementary check
- Strict adherence to REST — GET never mutates state, POST/PUT/DELETE/PATCH for mutations
| What developers do | What they should do |
|---|---|
| Relying solely on SameSite cookies for CSRF protection SameSite has gaps: GET state changes, subdomain attacks, SameSite=None cookies, and legacy browser support | Layer SameSite + CSRF tokens + Origin checking for defense in depth |
| Using GET requests for actions like delete, logout, or transfer SameSite=Lax allows cookies on top-level GET navigations, and GET requests can be triggered by img tags, link prefetching, and other passive elements | Use POST, PUT, or DELETE for all state-changing operations |
| Setting the CSRF double-submit cookie as HttpOnly HttpOnly prevents JavaScript from reading the cookie, which breaks the entire double-submit pattern. The session cookie should be HttpOnly, but the CSRF cookie should not be | The CSRF cookie must be readable by JavaScript so the client can send its value as a header |
| Thinking CORS prevents CSRF attacks Simple cross-origin requests (form POSTs with standard content types) are sent without preflight. The server processes the request before CORS blocks the response on the client | CORS only restricts reading responses — the request (and its side effects) still executes |
| Generating CSRF tokens with Math.random() Math.random() is not cryptographically secure — its output can be predicted. CSRF tokens must be unguessable | Use a cryptographically secure random generator (crypto.randomUUID() or crypto.getRandomValues()) |