CSRF: Why Cookies Are the Vulnerability and How to Eliminate It
CSRF exploits the browser's automatic cookie attachment. Understanding the attack mechanism explains why SameSite cookies largely solve it — and why APIs using Bearer tokens are immune by default.
The root cause
CSRF works because browsers automatically attach cookies to every matching request — regardless of which page triggered the request. An attacker does not need to steal your session cookie. They just need to make your browser send a request to a site where your cookie already exists.
If you are logged into bank.example.com and visit a malicious page, that page can silently trigger a POST to bank.example.com/transfer. Your browser sends your session cookie. The bank server receives an authenticated request and processes it.
The victim never knows it happened.
How CSRF exploits cookie behavior
GotchaWeb SecurityCookies are attached by the browser based on the destination domain, not the origin page. This is useful for navigation but creates a vulnerability for state-changing requests.
Prerequisites
- HTTP cookies
- Same-Origin Policy
- HTML forms
Key Points
- The browser sends cookies to matching domains automatically — no JavaScript required.
- A simple HTML form POST does not trigger CORS preflight and bypasses CORS-based defenses.
- CSRF attacks target state changes: fund transfers, password resets, account deletions.
- CSRF does not let the attacker read responses — only trigger actions.
- APIs using Authorization: Bearer headers are not vulnerable, because the browser does not auto-attach custom headers.
A concrete attack
The attacker hosts this on evil.example.com:
<!-- Invisible form that auto-submits on page load -->
<form id="csrf" method="POST" action="https://bank.example.com/transfer">
<input type="hidden" name="to" value="attacker_account" />
<input type="hidden" name="amount" value="5000" />
</form>
<script>document.getElementById('csrf').submit();</script>
When the victim visits evil.example.com:
- The form submits to
bank.example.com/transfer. - The browser attaches the victim's
bank.example.comsession cookie. - The bank's server validates the cookie and processes the transfer.
- The attacker's page cannot read the response — but it does not need to.
No stolen credentials. No XSS. Just an HTML form.
Note that CORS does not stop this. A form POST with standard content types does not trigger a preflight. The server receives the request before any CORS check applies.
Defense 1: SameSite cookies (the modern standard)
The SameSite attribute tells the browser when to attach a cookie to cross-origin requests:
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Strict
| Value | Behavior |
|---|---|
| Strict | Cookie never sent on cross-site requests. Highest protection. User arrives at your site with no session if they click a link from another page. |
| Lax | Cookie sent on top-level navigations (link clicks) but not on cross-site subresource requests (forms, fetch, images). Safe against most CSRF while preserving normal browsing. |
| None | Cookie always sent. Must be combined with Secure. Needed for cross-site widgets, OAuth flows, embedded iframes. |
For most applications, SameSite=Lax is the right default. It blocks the malicious form POST scenario while allowing users to follow links to your site from email or external pages and arrive authenticated.
SameSite=Strict breaks the experience when users click your links from external pages — they arrive logged out. Reserve it for high-security sessions like payment confirmation flows.
📝SameSite browser support and the legacy gap
Modern browsers (Chrome 80+, Firefox, Safari) default new cookies to Lax if SameSite is not set. Older browsers treat unset SameSite as None. If you need to support older browsers or have cookies that predate your SameSite deployment, supplement with CSRF tokens until you can confirm your user base has migrated.
Defense 2: CSRF tokens
Before SameSite was universal, CSRF tokens were the primary defense. They remain necessary for apps that use SameSite=None or need to support legacy browsers.
The pattern:
- Server generates a cryptographically random token per session (or per form).
- Token is embedded in every state-changing form as a hidden field.
- Server validates the token on every POST/PUT/DELETE request.
- Attacker cannot forge the token because they cannot read content from your domain.
<form method="POST" action="/transfer">
<input type="hidden" name="csrf_token" value="r8fK2mP9...">
<input type="text" name="amount">
<button>Transfer</button>
</form>
Server-side validation (pseudocode):
def handle_transfer(request):
token = request.form.get('csrf_token')
if not hmac.compare_digest(token, session['csrf_token']):
return 403
process_transfer(request)
Use hmac.compare_digest (or equivalent constant-time comparison) rather than ==. Timing attacks can determine token validity from string comparison timing differences at sufficient request volume.
Defense 3: Double-submit cookie
For stateless APIs or SPAs that cannot easily embed tokens in forms, the double-submit cookie pattern works without server-side token storage:
- Server sets a random value as a regular (JavaScript-readable) cookie:
csrf=r8fK2mP9. - Client reads the cookie and includes the value in every state-changing request as a header:
X-CSRF-Token: r8fK2mP9. - Server validates that the header value matches the cookie value.
An attacker's page cannot read the cookie value (SOP prevents cross-origin cookie access) and cannot set a custom X-CSRF-Token header (custom headers trigger CORS preflight, which your server will reject from malicious origins).
// SPA: read CSRF cookie and attach to all state-changing requests
const csrfToken = document.cookie.match(/csrf=([^;]+)/)?.[1];
fetch('/api/transfer', {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken },
body: JSON.stringify({ to: '...', amount: 5000 }),
});
SameSite cookies vs CSRF tokens
Both prevent CSRF. The right choice depends on your session model and browser support requirements.
- No application code changes required — just a cookie attribute
- Effective in all modern browsers without server-side state
- Lax allows link-click navigations; Strict breaks external-link flows
- Does not protect against subdomain attacks if a subdomain is compromised
- Works regardless of SameSite support
- Requires token generation, storage, and validation logic
- Per-request tokens provide stronger replay protection than per-session tokens
- Needed when SameSite=None is required (OAuth callbacks, embedded widgets)
Start with SameSite=Lax for your session cookie. Add CSRF tokens for any flows using SameSite=None, for legacy browser support, or for high-value operations (fund transfers, account deletion) where defense-in-depth is worth the complexity.
When CSRF does not apply
APIs that authenticate via Authorization: Bearer <token> in the request header are not vulnerable to CSRF. The bearer token is not a cookie — the browser does not attach it automatically. The attacker's page cannot set an Authorization header on a cross-origin request without triggering CORS preflight, which your server will reject.
This is why REST APIs consumed by SPAs using token-based auth in memory (not cookies) do not need CSRF protection. The automatic-cookie-attachment mechanism that CSRF exploits is not in play.
CSRF is specifically a cookie-session vulnerability. If your application has moved sessions entirely out of cookies — tokens stored in memory, no HttpOnly session cookies — CSRF protection is not needed.
⚠XSS defeats CSRF protection
Every CSRF defense assumes the attacker cannot run JavaScript on your domain. XSS breaks that assumption. An attacker who can inject JavaScript on your site can read CSRF tokens from the DOM, read JavaScript-accessible cookies, and make authenticated requests directly. CSRF tokens are meaningless if your site has XSS vulnerabilities. Fix XSS first.
A single-page application stores its session token in an HttpOnly cookie and makes API calls with fetch(). The server returns SameSite=Lax on the session cookie. An attacker creates a page with a hidden form that POSTs to the API. Does the attack succeed?
mediumThe API validates the session cookie on every request. No CSRF token is implemented.
AYes — the browser sends the cookie with the cross-origin form POST
Incorrect.SameSite=Lax blocks cookie attachment on cross-origin form submissions. The form POST from the attacker's page is treated as a cross-site subresource request, not a top-level navigation.BNo — SameSite=Lax prevents the session cookie from being attached to the cross-site POST
Correct!SameSite=Lax sends cookies on top-level navigations (link clicks) but blocks them on cross-site form submissions and subresource requests. The attacker's form POST arrives without the session cookie, and the server rejects it as unauthenticated.CNo — fetch() is immune to CSRF by default
Incorrect.fetch() itself is not the defense here. The defense is SameSite=Lax on the cookie. The attacker used an HTML form, not fetch().DYes — CORS headers must be explicitly set to block the request
Incorrect.CORS does not prevent form submissions from being sent. The browser sends the form POST regardless; CORS would only restrict JavaScript on the attacker's page from reading the response.
Hint:Focus on what SameSite=Lax does to cookie attachment for form submissions vs link-click navigations.