A DevForum user visits a forum on a completely unrelated site. The page looks normal — a discussion thread, a few images. Hidden in the HTML is this:
<!-- attacker's page — evil.com — do not ship this -->
<form id="f" action="https://devforum.com/account/delete" method="POST">
<input type="hidden" name="confirm" value="true" />
</form>
<script>document.getElementById('f').submit();</script>
The form submits the moment the page loads. The user’s browser sends a POST to devforum.com/account/delete. It attaches the DevForum session cookie automatically — the same cookie it attaches to every request to devforum.com. The server receives a valid session token, confirms the account deletion request, and returns 200.
The user never clicked anything on DevForum. The attacker’s site never received the response. The account is gone.
This is Cross-Site Request Forgery. The server did exactly what it was told — by the attacker.
Why the Browser Does This
Browsers were designed to attach cookies to requests unconditionally. When devforum.com set a session cookie, the browser committed to sending it with every future request to devforum.com — regardless of which page initiated the request.
This behaviour is fundamental to how the web works. Without it, clicking a link from a Google search result to a site where you’re already logged in would require you to log in again. Every navigation would lose session state.
CSRF is not a browser bug. It is the intentional default behaviour of cookies, used against you.
Why CORS Doesn’t Save You
The previous post covered how CORS blocks cross-origin response reads. Developers often assume that same protection extends to cross-site requests. It does not.
The browser still sends the request. The form POST to devforum.com/account/delete goes through. The server processes it. The state change happens. CORS only prevents evil.com’s JavaScript from reading the response — but the damage was done before the response arrived.
Why CSRF Still Works in 2026
Modern browsers defaulted SameSite to Lax starting in 2020, which blocks many CSRF vectors. (SameSite is a cookie attribute that controls when the browser includes cookies on cross-site requests — it’s covered fully in the next post. For now: Lax means cookies are sent on top-level navigations but not on cross-site form POSTs or fetch() calls.) But the attack is not solved. It survives in several real scenarios:
Subdomain attacks. If an attacker can run code on any subdomain of your domain, they can set cookies that scope to your apex domain — and those cookies are sent on same-site cross-origin requests that SameSite=Lax permits.
Top-level GET navigations. SameSite=Lax permits cookies on top-level navigations using safe methods. If a state-changing action can be triggered via a GET request (search, export, confirm via link), Lax does not protect it.
Legacy browsers. SameSite is not universally supported. Any user on an older browser — common in enterprise environments — receives no protection from browser-default SameSite behaviour.
Mobile WebViews. Some WebView implementations handle cookies differently from browser behaviour. SameSite semantics vary.
Explicitly SameSite=None cookies. Cookies set by legacy SSO systems, third-party integrations, or payment providers are often SameSite=None by requirement. They are fully vulnerable to CSRF.
The Attack in Full
The server’s perspective: a POST arrived with a valid session cookie, the right Content-Type, and a confirm=true body parameter. It looked identical to a legitimate account deletion request. Without an additional check that the server itself issued, there is no way to tell the difference.
That additional check is the synchronizer token.
The Synchronizer Token Pattern
The synchronizer token pattern (also called the CSRF token pattern) adds a server-generated secret to every state-changing request. The server issues the token, the client includes it, and the server validates it before acting. An attacker on evil.com has no way to know the current token — they cannot read responses from devforum.com due to SOP — so they cannot include it in a forged request.
How it works
- On session creation (or per request, for stricter security), the server generates a cryptographically random token and stores it server-side, associated with the session.
- The token is embedded in every HTML form that performs a state-changing action.
- On every state-changing request (POST, PUT, PATCH, DELETE), the server reads the token from the request body or a custom header, and compares it to the stored value.
- If they match — the request is legitimate. If not — 403.
// Express — CSRF token middleware (simplified)
import crypto from 'crypto';
function generateCsrfToken(req) {
const token = crypto.randomBytes(32).toString('hex');
req.session.csrfToken = token;
return token;
}
function validateCsrfToken(req, res, next) {
const tokenFromRequest =
req.body._csrf || // form body
req.headers['x-csrf-token'] || // fetch with custom header
req.headers['x-xsrf-token']; // Angular's default header name
if (!tokenFromRequest || tokenFromRequest !== req.session.csrfToken) {
return res.status(403).json({ error: 'CSRF token invalid or missing' });
}
next();
}
// Usage
app.post('/account/delete',
validateCsrfToken,
deleteAccountHandler
);
In a server-rendered HTML form:
<form action="/account/delete" method="POST">
<input type="hidden" name="_csrf" value="<%= csrfToken %>" />
<button type="submit">Delete account</button>
</form>
In a React/Vue app using fetch():
// Token delivered via a meta tag or an API endpoint
const token = document.querySelector('meta[name="csrf-token"]').content;
fetch('/api/account/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': token, // custom header — cannot be set by a cross-origin form
},
body: JSON.stringify({ confirm: true }),
credentials: 'include',
});
The forged request from evil.com arrives without _csrf — the attacker never loaded the settings page from DevForum’s server. The validateCsrfToken middleware returns 403 before any business logic runs.
The Double-Submit Cookie Pattern
The synchronizer token pattern requires server-side session storage to hold the token. For stateless APIs that do not maintain session state server-side, there is an alternative: the double-submit cookie.
The idea: set the same random value in both a cookie and in each state-changing request (as a header or body field). The server checks that the two values match. It does not need to store the value itself — it just validates that cookie and request agree.
// Server — set the CSRF cookie on login
res.cookie('csrfToken', crypto.randomBytes(32).toString('hex'), {
httpOnly: false, // JavaScript must be able to read this cookie to include it in headers
sameSite: 'strict',
secure: true,
});
// Client — read the cookie and include in every state-changing request
function getCookie(name) {
return document.cookie
.split('; ')
.find(row => row.startsWith(name + '='))
?.split('=')[1];
}
fetch('/api/account/delete', {
method: 'POST',
headers: {
'X-CSRF-Token': getCookie('csrfToken'), // must match the cookie value
},
credentials: 'include',
});
// Server — validate that header and cookie values match
function validateDoubleSubmit(req, res, next) {
const fromHeader = req.headers['x-csrf-token'];
const fromCookie = req.cookies.csrfToken;
if (!fromHeader || !fromCookie || fromHeader !== fromCookie) {
return res.status(403).json({ error: 'CSRF token mismatch' });
}
next();
}
Choosing Between the Two Patterns
| Synchronizer Token | Double-Submit Cookie | |
|---|---|---|
| Server state required | Yes — token stored in session | No — stateless |
| Subdomain-safe by default | Yes | Only with __Host- prefix on the cookie |
| Works with SPAs | Yes — token delivered via API or meta tag | Yes — token read from cookie by JS |
| Works without JavaScript | Yes — token in hidden form field | No — JS must read cookie and set header |
| Complexity | Moderate | Low |
For most server-rendered apps: synchronizer tokens. For stateless APIs where server-side session storage is not available: double-submit cookies — paired with SameSite=Strict and the __Host- prefix covered in the next post.
POST, PUT, PATCH, DELETE — all of them. GET endpoints should never perform state changes. If a state change requires a GET (e.g. email confirmation links), add a single-use signed token in the URL rather than relying on a session cookie alone.
A per-session CSRF token is adequate for most endpoints. For high-value actions — account deletion, email change, password reset — rotate the token per request. An intercepted token then has a one-request window of exposure.
CORS blocks response reads. The forged request still reaches the server. Every state-changing endpoint needs explicit CSRF token validation regardless of your CORS configuration.
Requiring Content-Type: application/json reduces but does not eliminate CSRF risk. Flash (before its deprecation) and some browser edge cases could send custom content types cross-origin. Token validation is not optional.
Tokens are the primary defence. Cookie attributes and Fetch metadata request headers are the secondary layer that handles the scenarios tokens miss. Both posts in this block are required for a complete defence-in-depth posture.