CSRF Protection: How to Stop Cross-Site Request Forgery
Cross-Site Request Forgery (CSRF) tricks a victim’s browser into sending an authenticated request to your site. Without CSRF protection, the browser automatically includes cookies with every request, so if you’re logged in, the forged request arrives with valid session cookies. Your server has no way to tell the difference between a request you initiated and one triggered by a hidden form on a malicious page.
SameSite cookies: the first line of defense
The SameSite attribute on session cookies controls whether browsers send them with cross-origin requests.
| Value | Behavior |
|---|---|
Strict | Cookie never sent on cross-site requests |
Lax | Cookie sent on same-site requests and top-level navigations (GET, HEAD) only |
None | Cookie sent on all cross-site requests; requires Secure |
Modern browsers switched the default from no restriction to Lax in 2020. This single change blocks CSRF on GET-based navigations without any code changes on your part.
Set-Cookie: sessionId=abc123; SameSite=Lax; Secure; HttpOnly
The problem is that SameSite=Lax still sends cookies with cross-site POST requests. For state-changing operations, you need a second line of defense.
Synchronizer token pattern
This is the most widely-deployed CSRF protection. Your server generates a cryptographically random token, stores it in the session, and includes it in every form as a hidden field. On submission, the server compares the token from the request body against the session value.
// Generate and expose CSRF token per session
app.use((req, res, next) => {
if (!req.session.csrfToken) {
req.session.csrfToken = crypto.randomBytes(32).toString('hex')
}
res.locals.csrfToken = req.session.csrfToken
next()
})
// Validate on POST/PUT/PATCH/DELETE
function validateCsrf(req, res, next) {
const token = req.body._csrf || req.headers['x-csrf-token']
if (!token || token !== req.session.csrfToken) {
return res.status(403).json({ error: 'Invalid CSRF token' })
}
next()
}
The server generates the token once per session and makes it available to templates through res.locals. Every state-changing route runs the validation middleware to reject requests that lack a matching token. On the client side, render this token into a hidden form field so the browser includes it when the user submits:
<form method="POST" action="/transfer">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<input type="text" name="amount">
<button type="submit">Send</button>
</form>
Note: the csurf middleware that once automated this is deprecated and unmaintained. Generate tokens manually with crypto.randomBytes() instead.
Double submit cookie pattern
This approach avoids server-side session storage. The server sets a token as a cookie and also renders it into the page (as a meta tag or template variable). The client reads the token and sends it as a custom header. The server checks that the header value matches the cookie.
// Server sets token cookie and exposes it to the page
app.get('/form', (req, res) => {
const token = crypto.randomBytes(32).toString('hex')
res.cookie('csrf-token', token, { sameSite: 'lax', secure: true, httpOnly: true })
res.render('form', { csrfToken: token })
})
// Client reads token from meta tag and sends as header
const token = document.querySelector('meta[name="csrf-token"]').content
fetch('/process', {
method: 'POST',
credentials: 'same-origin',
headers: { 'X-CSRF-Token': token },
body: new URLSearchParams({ field: 'value' })
})
This works because the Same-Origin Policy prevents evil.com from reading yoursite.com’s cookies. Without reading the token, the attacker cannot supply the matching header.
The __Host- cookie prefix prevents subdomains from setting cookies for the parent domain. This matters if you have third-party integrations or user-generated content on subdomains:
Set-Cookie: __Host-csrf-token=18XNjC4b8KVok4uw5RftR38Wgp2BFwql; Path=/; SameSite=Lax; Secure
Note: if your site has an XSS vulnerability, this pattern breaks down entirely. Attackers can steal the token from the DOM. Fix XSS first.
Custom request header (SPA pattern)
For JavaScript-heavy SPAs using fetch, you can skip tokens entirely. Add any custom header to your requests. The browser refuses to send cross-origin requests with custom headers before your code even runs, because the Same-Origin Policy blocks them.
// Server only checks for header presence
function requireCsrfHeader(req, res, next) {
if (!req.headers['x-yoursite-csrf']) {
return res.status(403).json({ error: 'CSRF header missing' })
}
next()
}
app.post('/api/delete-account', requireCsrfHeader, (req, res) => {
res.json({ deleted: true })
})
The server rejects any POST, PUT, PATCH, or DELETE request that arrives without the custom header. This check is simple and stateless, requiring no token storage. The client adds the same header to every fetch call, which the browser permits for same-origin requests but blocks for cross-origin ones:
// Client: any non-standard header works
fetch('/api/delete-account', {
method: 'POST',
credentials: 'same-origin',
headers: { 'X-YOURSITE-CSRF': '1' }
})
Any value works for the header. An attacker cannot set any custom header on a cross-origin request, so checking for its presence is sufficient.
This pattern only protects JavaScript requests. Traditional <form> submissions cannot add custom headers, so use a different pattern for form-based actions.
Fetch API credentials behavior
The credentials option in fetch controls cookie-sending behavior across origins:
| Value | Same-origin | Cross-origin |
|---|---|---|
same-origin (default) | Cookies sent | Cookies not sent |
include | Cookies sent | Cookies sent (requires Access-Control-Allow-Credentials) |
omit | Cookies not sent | Cookies not sent |
// Default: cookies only sent to same origin — safe by default
fetch('/api/data', { method: 'POST' })
// Sends cookies cross-origin — needs explicit CSRF protection
fetch('https://api.yoursite.com/data', {
credentials: 'include',
mode: 'cors',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'delete' })
})
Traditional <form> submissions behave differently. They send cookies on any POST, regardless of origin. This is why forms always need CSRF protection even when using same-site cookies.
Angular-Style XSRF-TOKEN Pattern
Many frameworks (Angular, Django) automatically rotate CSRF tokens using a cookie. The server sets the token in a cookie on every response. The client reads it and sends it back as a header. Each page load gets a fresh token.
// Server sets token on every response
app.all('*', (req, res, next) => {
res.cookie('XSRF-TOKEN', req.session.csrfToken(), {
sameSite: 'lax',
secure: true
})
next()
})
The server refreshes the token cookie on every response, which avoids the risk of a long-lived token being reused after a session ends. On the client side, wrap your fetch calls in a helper that reads the cookie and attaches the matching header automatically:
// Client wrapper that reads token from cookie and adds header
async function safeFetch(url, options = {}) {
const token = document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1]
return fetch(url, {
...options,
credentials: 'same-origin',
headers: {
...(token && { 'X-XSRF-TOKEN': token }),
...options.headers
}
})
}
// Use it instead of plain fetch
await safeFetch('/api/delete-account', { method: 'POST', body: JSON.stringify({}) })
This pattern is easy to implement and handles token rotation automatically.
Common Pitfalls
State-changing GET requests. Never perform state changes on GET. CSRF tokens should not be validated on GET, HEAD, or OPTIONS requests.
Tokens in URL query strings. URLs appear in server logs, browser history, and the Referer header sent to other sites. Always use request bodies or headers for tokens.
The csurf package is deprecated. It has known vulnerabilities and is unmaintained. Use crypto.randomBytes() directly.
SameSite=None without Secure. Browsers silently reject cookies that set SameSite=None without the Secure flag. Always include both.
CORS is not CSRF protection. CORS controls which origins can read responses. Cross-origin POST requests can still be sent. CORS does not stop that. You need both.
credentials: 'include' with cross-origin requests. This combination sends cookies cross-origin and requires explicit CSRF protection plus Access-Control-Allow-Credentials.
Subdomain compromise. If an attacker takes over a subdomain, they can often set cookies for the parent domain and bypass CSRF protections. Use the __Host- cookie prefix to prevent subdomains from setting your cookies.
Choose the right defense mix
CSRF protection works best when more than one signal is in play. Same-site cookies reduce risk for many apps, but they do not replace explicit validation when the stakes are high. A token check gives the server a way to distinguish a deliberate request from a blind cross-site form post. The important part is consistency: if your app uses one token flow for HTML forms and another for JSON requests, document both paths clearly so future changes do not weaken the boundary.
Keep the token flow simple
The easier it is to explain how a token moves from server to client and back again, the easier it is to maintain. Hidden token tricks tend to break when teams add new forms, SPAs, or background requests. Prefer a small, repeatable pattern that every page can follow. When the same pattern is used across routes, review gets faster and bug reports become easier to reproduce. A short, explicit flow also helps new contributors understand where the security check happens.
Watch browser behavior
Browsers differ in the small details that matter here: cookie handling, form submission, and credential defaults can all shape the real risk. Test the exact browser paths your users use, not just the happy path in a local demo. If a feature depends on a particular cookie attribute or request header, verify it in a real environment before you ship it. That habit keeps the defense grounded in actual behavior instead of in an idealized example.
See Also
- Fetch API and XMLHttpRequest, covering how credential handling works in fetch vs. XHR
- Authentication and Sessions, where CSRF tokens live and how sessions work
- XSS Prevention, which explains why fixing XSS matters for CSRF protection to work