CSRF Protection Patterns
Cross-Site Request Forgery (CSRF) tricks a victim’s browser into sending an authenticated request to your site. 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()
}
On the client side, render the token into a hidden field:
<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 — 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 })
})
// Client: any non-standard header works
fetch('/api/delete-account', {
method: 'POST',
credentials: 'same-origin',
headers: { 'X-YOURSITE-CSRF': '1' }
})
The header value can be anything. 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()
})
// 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.
See Also
- Fetch API and XMLHttpRequest — how credential handling works in fetch vs. XHR
- Authentication and Sessions — where CSRF tokens live and how sessions work
- XSS Prevention — why fixing XSS matters for CSRF protection to work