Skip to content

CSRF and SameSite Cookies

intermediate16 min read

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.

Mental Model

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:

  1. The victim is authenticated on bank.com (has a valid session cookie)
  2. The victim visits evil.com (or any compromised page)
  3. evil.com contains HTML/JS that triggers a request to bank.com
  4. The browser automatically attaches bank.com's cookies to that request
  5. bank.com processes 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.

Execution Trace
Login
User authenticates on bank.com
Browser stores session cookie for bank.com
Visit
User opens evil.com in another tab
Attacker page loads with hidden form
Forge
evil.com auto-submits POST to bank.com/transfer
The request originates from evil.com's page
Attach
Browser attaches bank.com cookies automatically
Cookies are sent based on destination domain, not origin
Execute
bank.com processes the transfer
Server sees valid session cookie, trusts the request

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.

Quiz
A user is logged into app.com. They visit evil.com, which contains an img tag pointing to app.com/api/delete-account. What happens?

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.com triggered 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.

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.

Quiz
A website sets a cookie without specifying the SameSite attribute. A cross-site form submits a POST request to that website. What happens in modern browsers?

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

  1. When the server renders a form (or sends a page), it generates a random token tied to the user's session
  2. The token is embedded in the HTML (in a hidden form field or meta tag)
  3. When the form is submitted, the token is sent along with the request
  4. 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 }),
});
Common Trap

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.

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

  1. The server sets a random value in both a cookie and the response body (or a header)
  2. The client reads the value from the cookie (via JavaScript) and sends it as a request header or form field
  3. 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.

Warning

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).

Quiz
In the double-submit cookie pattern, why can't the attacker on evil.com read the CSRF cookie value to forge the matching header?

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 Origin header is sometimes absent on same-origin requests in older browsers
  • The Referer header can be suppressed by Referrer-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.

Key Rules
  1. 1Never use GET for state-changing operations — Lax cookies are sent on cross-site GET navigations
  2. 2Layer defenses: SameSite + CSRF tokens + Origin checking. Never rely on a single mechanism
  3. 3Set SameSite=Strict for cookies that never need cross-site access (admin panels, internal tools)
  4. 4Always pair SameSite=None with Secure flag — browsers reject None without Secure
  5. 5CSRF tokens must be cryptographically random, tied to the session, and validated server-side on every mutating request
  6. 6The double-submit cookie must NOT be HttpOnly — JavaScript needs to read it to send it as a header
Quiz
Your app uses SameSite=Lax cookies. An attacker controls a subdomain (evil.yoursite.com). Can they perform a CSRF attack?

Putting It All Together: Defense in Depth

No single defense is bulletproof. The right approach layers multiple mechanisms:

DefenseProtects AgainstWeakness
SameSite=LaxMost cross-site POST/iframe attacksGET state changes, subdomain attacks
CSRF tokensForged requests from any originRequires server-side session state
Double-submit cookieForged requests (stateless)Subdomain cookie injection
Origin/Referer checkCross-origin requestsCan be absent, privacy-stripped
Custom request headersSimple request attacks (triggers preflight)Only works for AJAX, not form submissions

The production-grade approach:

  1. SameSite=Lax on all session cookies (you get this for free in modern browsers)
  2. CSRF tokens on all state-changing endpoints (synchronizer pattern for server-rendered apps, double-submit for SPAs)
  3. Origin header validation as a supplementary check
  4. Strict adherence to REST — GET never mutates state, POST/PUT/DELETE/PATCH for mutations
What developers doWhat 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())
Quiz
Which combination provides the strongest CSRF protection for a server-rendered web application?