Cross-Site Request Forgery (CSRF) attacks force authenticated users to execute unwanted actions on web applications where they're authenticated. This guide covers comprehensive CSRF protection strategies.
Understanding CSRF
How CSRF Works
- 1User logs into
example.com - 2Server sets session cookie
- 3User visits malicious site
evil.com - 4Evil site sends request to
example.com - 5Browser automatically includes session cookie
- 6Server processes request as legitimate user
``html <!-- Malicious site evil.com --> <form action="https://example.com/transfer" method="POST"> <input type="hidden" name="to" value="attacker"> <input type="hidden" name="amount" value="10000"> <input type="submit" value="Click me!"> </form>
<!-- Or auto-submit with JavaScript --> <body onload="document.forms[0].submit()"> <form action="https://example.com/transfer" method="POST"> <input type="hidden" name="to" value="attacker"> <input type="hidden" name="amount" value="10000"> </form> </body>
## CSRF Token Protection
### Generating CSRF Tokens
const crypto = require('crypto');
class CSRFProtection { constructor({ secret, sessionStore }) { this.secret = secret; this.sessionStore = sessionStore; }
// Generate secure random token generateToken() { return crypto.randomBytes(32).toString('hex'); }
// Create token with HMAC signature async createToken(sessionId) { const token = this.generateToken(); const timestamp = Date.now();
// Create signed token const signedToken = this.signToken(token, timestamp);
// Store in session await this.sessionStore.set(csrf:${sessionId}, { token, timestamp, used: false });
return signedToken; }
// Sign token with HMAC signToken(token, timestamp) { const data = ${token}|${timestamp}; const hmac = crypto .createHmac('sha256', this.secret) .update(data) .digest('hex');
return Buffer.from(${data}|${hmac}).toString('base64url'); }
// Verify token async verifyToken(sessionId, signedToken) { try { // Decode and parse token const parts = Buffer.from(signedToken, 'base64url').toString().split('|'); const [token, timestamp, signature] = parts;
// Verify signature const data = ${token}|${timestamp}; const expectedHmac = crypto .createHmac('sha256', this.secret) .update(data) .digest('hex');
if (signature !== expectedHmac) { throw new Error('Invalid token signature'); }
// Check token hasn't been used const stored = await this.sessionStore.get(csrf:${sessionId}); if (!stored || stored.token !== token || stored.used) { throw new Error('Invalid or used token'); }
// Check token age (max 1 hour) const age = Date.now() - parseInt(timestamp); if (age > 3600000) { throw new Error('Token expired'); }
// Mark token as used (one-time use) await this.sessionStore.set(csrf:${sessionId}, { ...stored, used: true });
return true; } catch (error) { console.error('CSRF verification failed:', error); return false; } } }
### Express.js Integration
const express = require('express'); const cookieParser = require('cookie-parser'); const csrf = require('csurf'); const app = express();
// Using csurf middleware const csrfProtection = csrf({ cookie: true });
app.use(express.json()); app.use(cookieParser());
// Provide CSRF token to frontend app.get('/api/csrf-token', csrfProtection, (req, res) => { res.json({ csrfToken: req.csrfToken() }); });
// Protected routes app.post('/api/transfer', csrfProtection, async (req, res) => { const { to, amount } = req.body;
// Process transfer await processTransfer(req.user.id, to, amount);
res.json({ success: true }); });
// Custom CSRF middleware class CustomCSRF { constructor(csrfProtection) { this.protection = csrfProtection; }
middleware() { return (req, res, next) => { // Skip CSRF for GET requests (safe methods) if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) { return next(); }
this.protection(req, res, next); }; }
// Middleware to provide token provideToken() { return (req, res, next) => { res.locals.csrfToken = req.csrfToken(); next(); }; } }
const customCSRF = new CustomCSRF(csrfProtection()); app.use(customCSRF.middleware()); app.use(customCSRF.provideToken());
## Same-Site Cookies
### Cookie Configuration
// Express session with SameSite cookies const session = require('express-session');
app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false, cookie: { httpOnly: true, secure: process.env.NODE_ENV === 'production', // HTTPS only in production sameSite: 'strict', // 'strict' | 'lax' | 'none' maxAge: 24 * 60 * 60 * 1000 // 24 hours } }));
// Setting cookies manually res.cookie('session', sessionId, { httpOnly: true, secure: true, sameSite: 'strict', domain: '.example.com', path: '/' });
### Same-Site Attribute Values
``javascript
// Strict: Never send cookies with cross-site requests
res.cookie('session', value, { sameSite: 'strict' });
// Lax: Send cookies with safe cross-site requests (GET)
res.cookie('session', value, { sameSite: 'lax' });
// None: Send cookies with all cross-site requests (requires Secure)
res.cookie('session', value, { sameSite: 'none', secure: true });Frontend Implementation
React with CSRF Tokens
import { useEffect, useState } from 'react';
function CSRFProtectedForm() {
const [csrfToken, setCsrfToken] = useState('');
useEffect(() => {
// Fetch CSRF token on mount
fetch('/api/csrf-token')
.then(res => res.json())
.then(data => setCsrfToken(data.csrfToken));
}, []);
const handleSubmit = async (e) => {
e.preventDefault();
const formData = {
to: 'recipient@example.com',
amount: 100,
_csrf: csrfToken // Include CSRF token
};
const response = await fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken // Or via header
},
credentials: 'include', // Include cookies
body: JSON.stringify(formData)
});
const result = await response.json();
console.log('Transfer result:', result);
};
return (
<form onSubmit={handleSubmit}>
<input type="hidden" name="_csrf" value={csrfToken} />
<input
type="text"
name="to"
placeholder="Recipient"
required
/>
<input
type="number"
name="amount"
placeholder="Amount"
required
/>
<button type="submit">Transfer</button>
</form>
);
}Axios Configuration
import axios from 'axios';
// Create axios instance with CSRF
const api = axios.create({
baseURL: '/api',
withCredentials: true // Include cookies
});
// Request interceptor to add CSRF token
api.interceptors.request.use(async (config) => {
// Fetch CSRF token if not in cache
if (!config.headers['X-CSRF-Token']) {
const { data } = await axios.get('/api/csrf-token');
config.headers['X-CSRF-Token'] = data.csrfToken;
}
return config;
}, error => Promise.reject(error));
// Usage
async function transferFunds(to, amount) {
try {
const response = await api.post('/transfer', { to, amount });
return response.data;
} catch (error) {
console.error('Transfer failed:', error);
}
}Double Submit Cookie Pattern
class DoubleSubmitCSRF {
// Generate token and set in both cookie and header
static middleware() {
return (req, res, next) => {
const token = crypto.randomBytes(32).toString('hex');
// Set in cookie
res.cookie('csrf_token', token, {
httpOnly: false, // Must be accessible to JavaScript
secure: true,
sameSite: 'strict'
});
// Provide token to template
res.locals.csrfToken = token;
next();
};
}
// Verify token matches
static verify() {
return (req, res, next) => {
const cookieToken = req.cookies.csrf_token;
const headerToken = req.headers['x-csrf-token'];
const bodyToken = req.body._csrf;
// Check if token exists
if (!cookieToken) {
return res.status(403).json({ error: 'Missing CSRF cookie' });
}
// Verify token matches
if (headerToken !== cookieToken && bodyToken !== cookieToken) {
return res.status(403).json({ error: 'Invalid CSRF token' });
}
next();
};
}
}
app.use(DoubleSubmitCSRF.middleware());
app.post('/api/protected', DoubleSubmitCSRF.verify(), (req, res) => {
res.json({ success: true });
});Additional Protection Strategies
Origin/Referer Header Validation
function validateOrigin(allowedOrigins) {
return (req, res, next) => {
const origin = req.headers.origin;
const referer = req.headers.referer;
// Skip for same-origin requests
if (req.headers.host && origin) {
const originHost = new URL(origin).host;
const reqHost = req.headers.host;
if (originHost === reqHost) {
return next();
}
}
// Validate against allowed origins
if (origin && allowedOrigins.includes(origin)) {
return next();
}
// Check referer as fallback
if (referer) {
const refererHost = new URL(referer).host;
if (allowedOrigins.some(allowed => refererHost.includes(new URL(allowed).host))) {
return next();
}
}
res.status(403).json({ error: 'Invalid origin' });
};
}
app.post('/api/protected',
validateOrigin(['https://example.com', 'https://app.example.com']),
(req, res) => {
res.json({ success: true });
}
);Custom Headers
// Require custom header that browsers can't set from forms
function requireCustomHeader() {
return (req, res, next) => {
// Check for custom request header
if (!req.headers['x-requested-with']) {
return res.status(403).json({ error: 'Missing required header' });
}
next();
};
}
// Frontend must add header
fetch('/api/protected', {
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
});Testing CSRF Protection
Automated Tests
const request = require('supertest');
const app = require('./app');
describe('CSRF Protection', () => {
it('should reject requests without CSRF token', async () => {
const response = await request(app)
.post('/api/transfer')
.send({ to: 'attacker', amount: 1000 })
.expect(403);
expect(response.body.error).toContain('CSRF');
});
it('should accept requests with valid CSRF token', async () => {
// Get CSRF token
const tokenResponse = await request(app)
.get('/api/csrf-token')
.expect(200);
const { csrfToken } = tokenResponse.body;
// Use token in request
const response = await request(app)
.post('/api/transfer')
.set('X-CSRF-Token', csrfToken)
.send({ to: 'recipient', amount: 100 })
.expect(200);
expect(response.body.success).toBe(true);
});
});Best Practices
- 1Use SameSite=Strict: Best protection, but may break some functionality
- 2Implement CSRF Tokens: Required for state-changing operations
- 3Verify Origin/Referer: Additional layer of protection
- 4GET Requests Safe: Never change state with GET requests
- 5Token Per Session: Generate unique token per session
- 6Token Per Request: Even better: rotate tokens per request
- 7Short Expiration: Tokens should expire quickly (1 hour)
- 8Secure Cookie: Always use HttpOnly and Secure flags
Conclusion
CSRF protection is essential for web application security. Combine multiple defenses: SameSite cookies, CSRF tokens, and origin validation. Implement defense in depth, test thoroughly, and stay updated on emerging threats and best practices.