// LOADING
// LOADING
// LOADING_ARTICLE
Cross-site request forgery (CSRF) tricks a browser into sending an authenticated request to your server on behalf of an attacker's page. The attack works because browsers automatically attach cookies to every request matching the cookie's domain — including requests originating from a third-party site.
The good news is that modern browser changes have dramatically reduced the risk. The less good news is that CSRF is not fully mitigated by default, and the conditions under which it still matters are easy to overlook.
Imagine a user is logged in to bank.example.com. Their session cookie is stored in the browser. An attacker crafts a page at evil.example.com containing:
html<form action="https://bank.example.com/transfer" method="POST"> <input type="hidden" name="amount" value="1000" /> <input type="hidden" name="to" value="attacker-account" /> </form> <script>document.forms[0].submit();</script>
When the victim visits evil.example.com, the form auto-submits. The browser attaches the bank's session cookie to the POST request. Without a CSRF defence, the bank's server processes the transfer.
CSRF targets state-changing operations (transfers, password changes, email updates). Read-only endpoints are not a target because the attacker's page cannot read the cross-origin response (blocked by the Same-Origin Policy).
The SameSite cookie attribute controls whether the browser sends a cookie on cross-site requests.
| SameSite value | Behaviour |
|---|---|
| Strict | Cookie never sent on cross-site requests, including top-level navigations |
| Lax | Cookie sent on top-level GET navigations; blocked on cross-site POST, fetch, iframe |
| None | Cookie always sent (requires Secure) |
Since 2021, Chrome defaults cookies to SameSite=Lax when no attribute is specified. Firefox and Safari followed. This means that for the vast majority of applications, POST-based CSRF attacks are blocked by default because the session cookie is not sent on cross-site POST requests.
Set this explicitly — never rely on the browser default:
ts// Next.js Route Handler (app/api/auth/login/route.ts) response.cookies.set('session', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', maxAge: 60 * 60 * 24 * 7, // 7 days });
httpOnly: true also prevents JavaScript from reading the cookie, which protects against session theft via XSS — see preventing XSS in React and Next.js for how to close that angle.
SameSite=Lax is not a complete defence in two situations:
SameSite=Lax allows cookies on top-level GET navigations. If your application performs state changes via GET (for example, /confirm-email?token=... that also logs the user in, or a like button implemented as a plain link), an attacker can trigger them with <img src="..."> or a link.
Fix: Use POST (or at minimum a fetch with a CSRF token) for all state-changing operations. HTTP semantics require this anyway.
Cookies with domain=.example.com are shared across subdomains. If a subdomain is vulnerable to takeover (abandoned staging environment, user-uploaded content), an attacker can operate from a same-site origin and bypass SameSite entirely. This is a more advanced attack, but worth knowing when your cookie scope is broad.
Browsers older than ~2020 do not support SameSite. If your user base includes these, CSRF tokens remain necessary.
A CSRF token is an unpredictable value tied to the user's session. The server issues it; the client sends it back with every state-changing request; the server validates it. Because the attacker's origin cannot read your site's response (blocked by CORS), they cannot obtain a valid token.
A stateless implementation that scales well:
SameSite=Strict cookie AND embed it in the HTML form or return it in a header.tsimport { randomBytes } from 'crypto'; export function generateCsrfToken(): string { return randomBytes(32).toString('hex'); } export function validateCsrfToken(cookieToken: string | undefined, headerToken: string | undefined): boolean { if (!cookieToken || !headerToken) return false; // Constant-time comparison to prevent timing attacks const buf1 = Buffer.from(cookieToken); const buf2 = Buffer.from(headerToken); if (buf1.length !== buf2.length) return false; return require('crypto').timingSafeEqual(buf1, buf2); }
Send the token from the client side on every mutating fetch:
tsasync function postData(url: string, body: object) { const csrfToken = document.cookie .split('; ') .find((row) => row.startsWith('csrf=')) ?.split('=')[1]; return fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken ?? '', }, body: JSON.stringify(body), }); }
A common misconception is that JSON APIs are inherently CSRF-safe because attackers cannot set Content-Type: application/json in a cross-site form submission. This was largely true with older browser CORS preflight behaviour, but fetch with mode: 'no-cors' can send a text/plain body that some parsers accept, and browser behaviour has edge cases. The safe rule: protect all state-changing API endpoints regardless of content type.
For a broader look at API security patterns, see how to design APIs for Next.js applications and securing REST APIs. For authentication context, how modern authentication works covers where session cookies fit in the broader auth picture.
SameSite=Lax (minimum) or StrictHttpOnly and SecureOrigin or Referer header as an additional check on sensitive endpointsSameSite=Lax has made CSRF significantly harder to exploit in modern browsers. That said, relying on a browser default for a security property is fragile. Set SameSite explicitly, understand the cases where it falls short, and add token-based protection for high-value operations or when your audience includes older browsers. Combined with the XSS mitigations described in the React and Next.js XSS prevention guide, these controls cover the two most common client-side attack vectors.