CSRF Protection
Problem
A user logs into your banking app, leaves the tab open, and wanders off to read their email. One of those emails links to a cute cat video. They click it. The page loads, the cat is adorable, and meanwhile a hidden form on that same page has quietly submitted a POST to https://yourbank.com/transfer moving $5,000 to an attacker’s account.
The attack works because browsers automatically attach cookies to every request bound for a domain, no matter who triggered the request or where it came from. The malicious page doesn’t need to steal the session cookie or break any encryption. It just needs to point a request at your server, and the browser helpfully includes the victim’s authenticated session cookie for free. Your server receives a perfectly valid, fully authenticated request and processes it. As far as the server can tell, the logged-in user asked for that transfer.
This is cross-site request forgery, and it shows up anywhere a state-changing action relies solely on cookie authentication: fund transfers, password changes, email updates, account deletion, adding admin users. The attacker never sees a single response and never reads any of your data. They don’t need to. They only need the victim to load a page, an auto-submitting form, an <img> tag with a crafted src, or a fetch buried in a script while a valid session is active somewhere in the browser. Cookie authentication answers the question “is this user logged in?” but never the question “did this user actually intend to make this request?”
Solution
The fix is to demand proof that a request came from your own application, not from someone else’s page. That proof is a CSRF token: a cryptographically random, unguessable value generated per session that the attacker has no way to know or predict.
The synchronizer token pattern keeps the canonical token in server-side session state. The server hands the token to your application, typically embedded in a <meta> tag in the HTML or returned from an endpoint, and your client echoes it back in a custom header like X-CSRF-Token on every POST, PUT, PATCH, and DELETE. The server compares the submitted token against the one in the session and rejects anything that doesn’t match.
The double-submit cookie pattern avoids server-side session storage. The server sets the token in a cookie and the client reads that cookie and copies its value into a request header. The server then checks that the cookie value and the header value match. An attacker’s page can trigger the cookie to be sent automatically, but it cannot read the cookie’s contents to replicate it in the header, so the two won’t line up.
Both patterns lean on the same browser guarantee: the same-origin policy stops a page on evil.com from reading content, cookies, or response bodies belonging to yourbank.com. The attacker can cause a request, but cannot read the token needed to make that request valid. Without a correct token in the header, the forged request is rejected before it touches your business logic.
Layer SameSite cookies on top as defense in depth. Setting SameSite=Lax or SameSite=Strict on the session cookie tells the browser not to attach it to cross-site requests in the first place, which neutralizes many CSRF vectors at the source. Treat this as an additional layer rather than a replacement: older browsers ignore it, and Lax still permits some top-level navigations.
This pattern requires server cooperation. The client cannot protect itself alone, since the whole point is that the server validates a secret the client merely relays.
Example
The client’s job is small and mechanical: read the token your server provided and attach it as a header on every state-changing request. The examples below wrap fetch so that token plumbing happens in one place rather than being copy-pasted across every call site.
Client-Side Token Attachment
import { useCallback } from 'react';
function getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.content ?? '';
}
const UNSAFE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
export function useCsrfFetch() {
return useCallback((url, options = {}) => {
const method = (options.method ?? 'GET').toUpperCase();
const headers = new Headers(options.headers);
if (UNSAFE_METHODS.has(method)) {
headers.set('X-CSRF-Token', getCsrfToken());
}
return fetch(url, { ...options, headers, credentials: 'same-origin' });
}, []);
}
function TransferButton() {
const csrfFetch = useCsrfFetch();
const handleTransfer = () =>
csrfFetch('/api/transfer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: 100 }),
});
return <button onClick={handleTransfer}>Transfer $100</button>;
} <script setup>
function getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.content ?? '';
}
const UNSAFE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
function csrfFetch(url, options = {}) {
const method = (options.method ?? 'GET').toUpperCase();
const headers = new Headers(options.headers);
if (UNSAFE_METHODS.has(method)) {
headers.set('X-CSRF-Token', getCsrfToken());
}
return fetch(url, { ...options, headers, credentials: 'same-origin' });
}
function handleTransfer() {
return csrfFetch('/api/transfer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: 100 }),
});
}
</script>
<template>
<button @click="handleTransfer">Transfer $100</button>
</template> <script context="module">
function getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.content ?? '';
}
const UNSAFE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
export function csrfFetch(url, options = {}) {
const method = (options.method ?? 'GET').toUpperCase();
const headers = new Headers(options.headers);
if (UNSAFE_METHODS.has(method)) {
headers.set('X-CSRF-Token', getCsrfToken());
}
return fetch(url, { ...options, headers, credentials: 'same-origin' });
}
</script>
<script>
function handleTransfer() {
return csrfFetch('/api/transfer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: 100 }),
});
}
</script>
<button on:click={handleTransfer}>Transfer $100</button> function getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.content ?? '';
}
const UNSAFE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
export function csrfFetch(url, options = {}) {
const method = (options.method ?? 'GET').toUpperCase();
const headers = new Headers(options.headers);
if (UNSAFE_METHODS.has(method)) {
headers.set('X-CSRF-Token', getCsrfToken());
}
return fetch(url, { ...options, headers, credentials: 'same-origin' });
}
class TransferButton extends HTMLElement {
connectedCallback() {
this.innerHTML = `<button>Transfer $100</button>`;
this.querySelector('button').addEventListener('click', () =>
csrfFetch('/api/transfer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: 100 }),
})
);
}
}
customElements.define('transfer-button', TransferButton); Reading the Token From a Cookie
When you use the double-submit pattern instead of a meta tag, the server sets a readable token cookie and the client copies it into the header. Only the client and the server agree on the value; a cross-site page can send the cookie but cannot read it.
function getCookie(name) {
return document.cookie
.split('; ')
.find((row) => row.startsWith(`${name}=`))
?.split('=')[1] ?? '';
}
function csrfFetch(url, options = {}) {
const headers = new Headers(options.headers);
headers.set('X-CSRF-Token', getCookie('csrf_token'));
return fetch(url, { ...options, headers, credentials: 'same-origin' });
}
Server-Side Issuing and Validation
The server is where protection actually happens. This Express example uses the double-submit pattern: it issues a random token in a readable cookie, then verifies that the header matches the cookie on every unsafe request.
import crypto from 'node:crypto';
import express from 'express';
import cookieParser from 'cookie-parser';
const app = express();
app.use(cookieParser());
app.use(express.json());
const UNSAFE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
// Issue a token cookie if one isn't already present.
app.use((req, res, next) => {
if (!req.cookies.csrf_token) {
const token = crypto.randomBytes(32).toString('hex');
res.cookie('csrf_token', token, {
sameSite: 'lax',
secure: true,
// Readable by JS so the client can echo it back in the header.
httpOnly: false,
});
req.cookies.csrf_token = token;
}
next();
});
// Reject unsafe requests whose header doesn't match the cookie.
app.use((req, res, next) => {
if (!UNSAFE_METHODS.has(req.method)) return next();
const cookieToken = req.cookies.csrf_token;
const headerToken = req.get('X-CSRF-Token');
const valid =
cookieToken &&
headerToken &&
cookieToken.length === headerToken.length &&
crypto.timingSafeEqual(Buffer.from(cookieToken), Buffer.from(headerToken));
if (!valid) {
return res.status(403).json({ error: 'Invalid CSRF token' });
}
next();
});
app.post('/api/transfer', (req, res) => {
// Reaching here means the CSRF check passed.
res.json({ status: 'ok', amount: req.body.amount });
});
app.listen(3000);
Use crypto.timingSafeEqual rather than === so comparison time doesn’t leak information about how many characters matched. For the synchronizer pattern, store the token in req.session.csrfToken instead of a cookie and compare the header against that.
Benefits
- Forged requests from third-party pages are rejected because the attacker cannot supply a valid token they were never able to read.
- Protects exactly the dangerous operations that matter: fund transfers, password changes, account deletion, privilege escalation.
- The same-origin policy does the heavy lifting for free, so the token only needs to be unguessable, not encrypted.
- Pairs naturally with SameSite cookies for layered defense, so a gap in one mechanism is covered by the other.
- The client side is a thin wrapper around
fetchthat you write once and reuse everywhere, keeping call sites clean. - The double-submit variant needs no server-side session store, making it easy to scale across stateless servers.
- Established patterns and battle-tested middleware exist for every major server framework, so you rarely implement the crypto yourself.
- Validation failures are visible and loud (
403), making misconfiguration easy to catch in development rather than in production.
Tradeoffs
- Every state-changing request now carries token plumbing, and any call that forgets the header silently breaks until you trace it back.
- The server must generate, store, and securely compare tokens, adding crypto and session concerns to code that was previously just reading a cookie.
- Legitimate cross-origin clients break by design, so native mobile apps, third-party integrations, and public APIs need a different scheme such as bearer tokens.
- Single-page apps must keep the token fresh, refetching or rotating it after login, logout, or session renewal, or requests start failing mid-session.
- The double-submit pattern requires a JavaScript-readable cookie, which is fine for the token but a footgun if you accidentally apply the same setting to sensitive cookies.
- SameSite cookies alone are not a complete defense: older browsers ignore the attribute entirely, and
Laxstill allows top-level cross-site navigations. - Caching and CDNs complicate token delivery, since a page cached with one user’s token must never be served to another user.
- Naive string comparison can leak timing information, so validation has to use constant-time comparison to be genuinely safe.
Summary
CSRF attacks succeed because cookies are attached automatically, letting a malicious page trigger authenticated actions a user never intended. Defend against them by requiring an unguessable per-session token, delivered via the synchronizer or double-submit pattern, on every state-changing request, and validate it on the server before doing any work. Add SameSite cookies as a second layer, and remember that real protection lives on the server: the client only relays the secret it was handed.