The problem
A user logs into your banking app and leaves the tab open while they read their email. One of those emails links to a cat video, and they click it. While the video plays, a hidden form on that same page submits a POST to https://yourbank.com/transfer moving $5,000 to an attacker’s account.
The pattern
To stop this, your server requires proof that each request came from your own application and not from someone else’s page. The proof is a CSRF token, which is a cryptographically random value that the server generates for each session, so an attacker has no way to guess or predict it.
Problem
The attack works because browsers automatically attach cookies to every request sent to a domain, whichever page triggered the request. The malicious page doesn’t need to steal the session cookie or break any encryption. It only needs to send a request to your server, and the browser adds the victim’s session cookie to it. Your server receives a valid, 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 only on cookie authentication, such as fund transfers, password changes, email updates, account deletion, and adding admin users. The attacker never sees the response or reads any of your data, and doesn’t need to. All it takes is for the victim to load a page containing an auto-submitting form or an <img> tag with a crafted src while a valid session is active somewhere in the browser. A cookie tells the server the user is logged in, but not whether the user meant to make this request.
Solution
The synchronizer token pattern stores the token in the user’s session on the server. The server sends the token to your application, usually in a <meta> tag in the HTML or from an API endpoint, and your client code sends it back in a custom header such as X-CSRF-Token on every POST, PUT, PATCH, and DELETE request. The server compares the token it receives with the one in the session and rejects any request where they don’t match.
The double-submit cookie pattern doesn’t need any session storage on the server. 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 cause the browser to send the cookie, but it can’t read the cookie’s value to copy it into the header, so the header won’t contain the right value.
Both patterns depend on the browser’s same-origin policy, which stops a page on evil.com from reading cookies or response bodies that belong to yourbank.com. The attacker can make the browser send a request, but can’t read the token that would make that request valid. If the header doesn’t contain the correct token, the server rejects the forged request before any of your business logic runs.
SameSite cookies add a second layer of defense. If you set SameSite=Lax or SameSite=Strict on the session cookie, the browser doesn’t attach it to cross-site requests at all, so many CSRF attacks are blocked before the request gets to your server. It’s still worth keeping the tokens, because older browsers ignore SameSite, and Lax still allows some top-level navigations.
This pattern needs work on the server as well as the client. Client code can’t provide this protection, because it comes from the server checking a secret that the client only passes back.
Example
On the client, your code only has to read the token that your server provided and add it as a header on every request that changes state. The examples below wrap fetch, so the code that adds the token is in one place instead of being copied into 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 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 onclick={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
With the double-submit pattern instead of a meta tag, the server sets a token cookie that JavaScript can read, and the client copies its value into the header.
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 real protection happens on the server. This Express example uses the double-submit pattern. It sets a random token in a cookie that JavaScript can read, then checks 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 = Buffer.from(req.cookies.csrf_token ?? '');
const headerToken = Buffer.from(req.get('X-CSRF-Token') ?? '');
// timingSafeEqual throws if the buffers differ in length, so check that first.
const valid =
cookieToken.length > 0 &&
cookieToken.length === headerToken.length &&
crypto.timingSafeEqual(cookieToken, 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);
The comparison uses crypto.timingSafeEqual instead of ===, so the time the comparison takes doesn’t give away how many characters matched. For the synchronizer pattern, you’d store the token in req.session.csrfToken instead of a cookie and compare the header against that.
Benefits
- The server rejects forged requests from other sites, because the attacker never had a way to read the token and so can’t send a valid one.
- It protects the actions an attacker is most likely to go after, such as fund transfers, password changes, account deletion, and privilege escalation.
- The browser’s same-origin policy already stops the attacker from reading the token, so the token only needs to be impossible to guess, and doesn’t need to be encrypted.
- Tokens work alongside SameSite cookies, so if one of them fails, the other can still block the attack.
- On the client, all you need is a small wrapper around
fetchthat you write once and reuse, so the code that makes each request stays simple. - The double-submit version doesn’t need a session store on the server, so it’s easy to run across many stateless servers.
- The major server frameworks have well-tested CSRF middleware, so you rarely have to write the cryptography code yourself.
- A request that fails the check gets a
403response, so you can find setup mistakes in development instead of in production.
Tradeoffs
- Every state-changing request now has to include the token, and if a call leaves out the header, it keeps failing with a
403until you find it and fix it. - Your server has to generate tokens and compare them securely, so code that used to just read a cookie now has to deal with cryptography and sessions too.
- Legitimate cross-origin clients are blocked as well, so native mobile apps and third-party integrations need a different approach, such as bearer tokens.
- In a single-page app, you have to keep the token up to date by fetching or rotating it after login or when the session is renewed, or requests might start failing partway through a session.
- The double-submit pattern needs a cookie that JavaScript can read, which is fine for the token, but it’s dangerous if you accidentally use the same setting on sensitive cookies.
- The basic double-submit pattern shown here accepts any cookie and header that match, so an attacker who can set cookies for your domain, from a compromised subdomain for example, can plant a matching pair. The OWASP cheat sheet recommends signing the token with an HMAC tied to the user’s session instead.
- SameSite cookies still leave some attacks open, because older browsers ignore the attribute entirely, and
Laxstill allows top-level cross-site navigations. - Caching and CDNs make it harder to deliver the token, because a page cached with one user’s token must never be served to another user.
- A plain string comparison can leak timing information, so your validation code should use a constant-time comparison.
Summary
CSRF attacks work because the browser attaches cookies automatically, so a malicious page can trigger logged-in actions that the user never meant to take. To defend against them, your app sends an unguessable per-session token with every request that changes state, using either the synchronizer pattern or the double-submit pattern, and the server checks the token before doing any work. SameSite cookies add a second layer of protection. The server does the real checking, and the client only passes back the token it was given.