CSRF Attacks Explained: Tokens, SameSite Cookies & Modern Defenses
What Is CSRF and Why Does It Still Work?
Cross-Site Request Forgery (CSRF) tricks a user's browser into making unintended requests to a site where they're already authenticated. The browser automatically includes cookies — so the server thinks it's a legitimate request.
How a CSRF Attack Works
1. User logs into bank.com (session cookie set)
2. User visits evil.com (in another tab)
3. evil.com contains hidden form:
<form action="https://bank.com/transfer" method="POST">
<input type="hidden" name="to" value="attacker-account" />
<input type="hidden" name="amount" value="10000" />
</form>
<script>document.forms[0].submit();</script>
4. Browser sends the request WITH the user's bank.com session cookie
5. Bank processes the transfer — it looks legitimate
This works because the browser automatically attaches cookies to any request matching the cookie's domain, regardless of which website initiated the request.
CSRF Attack Variants
1. Form-Based CSRF (Classic)
<!-- Hidden auto-submitting form -->
<body onload="document.getElementById('csrf').submit()">
<form id="csrf" action="https://target.com/api/change-email" method="POST">
<input type="hidden" name="email" value="attacker@evil.com" />
</form>
</body>
2. Image Tag CSRF (GET Requests)
<!-- Triggers GET request silently -->
<img src="https://target.com/api/delete-account?confirm=true" width="0" height="0" />
3. XMLHttpRequest CSRF
// Works if CORS allows it or for simple requests
fetch('https://target.com/api/change-password', {
method: 'POST',
credentials: 'include', // Sends cookies
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'new_password=hacked123'
});
4. JSON-Based CSRF
<!-- Exploiting content-type bypass -->
<form action="https://target.com/api/update-profile" method="POST" enctype="text/plain">
<input name='{"role":"admin","ignore":"' value='"}' />
</form>
This sends: {"role":"admin","ignore":"="} — valid JSON that changes the user's role.
Defense #1: CSRF Tokens (Synchronizer Pattern)
The most reliable defense. Generate a random token, embed it in forms, and verify on the server.
// Server: Generate CSRF token
import crypto from 'crypto';
function generateCsrfToken(session: Session): string {
const token = crypto.randomBytes(32).toString('hex');
session.csrfToken = token;
return token;
}
// Server: Verify CSRF token
function verifyCsrfToken(req: Request, session: Session): boolean {
const token = req.body._csrf || req.headers['x-csrf-token'];
if (!token || token !== session.csrfToken) {
return false;
}
// Rotate token after use (one-time use)
session.csrfToken = crypto.randomBytes(32).toString('hex');
return true;
}
// Express middleware
function csrfProtection(req: Request, res: Response, next: NextFunction) {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
return next(); // Safe methods don't need CSRF protection
}
if (!verifyCsrfToken(req, req.session)) {
return res.status(403).json({ error: 'Invalid CSRF token' });
}
next();
}
<!-- Client: Include token in forms -->
<form method="POST" action="/api/transfer">
<input type="hidden" name="_csrf" value="{{csrfToken}}" />
<input name="amount" />
<button type="submit">Transfer</button>
</form>
Defense #2: SameSite Cookies
// Set SameSite attribute on session cookies
res.cookie('session', token, {
httpOnly: true,
secure: true,
sameSite: 'lax', // Blocks cross-site POST requests
// sameSite: 'strict' // Blocks ALL cross-site requests (breaks OAuth flows)
maxAge: 3600000,
});
| SameSite Value | Cross-Site POST | Cross-Site GET | OAuth Flows |
|---|---|---|---|
| None | ✅ Sent (vulnerable) | ✅ Sent | ✅ Works |
| Lax (default) | ❌ Blocked | ✅ Sent (top-level) | ✅ Works |
| Strict | ❌ Blocked | ❌ Blocked | ❌ Breaks |
Why SameSite Isn't Enough Alone
- Lax allows GET requests — if your app has state-changing GET endpoints, they're still vulnerable
- Subdomain bypass — attacker on sub.target.com can still send cookies
- Browser bugs — Some older browsers don't enforce SameSite properly
- WebSocket bypass — SameSite doesn't apply to WebSocket connections
Always combine SameSite cookies with CSRF tokens for defense-in-depth.
Defense #3: Double Submit Cookie
For stateless APIs where you can't store tokens in sessions:
// Set a random CSRF cookie
const csrfValue = crypto.randomBytes(32).toString('hex');
res.cookie('csrf', csrfValue, {
httpOnly: false, // Client needs to read this
secure: true,
sameSite: 'lax'
});
// Middleware: Verify cookie value matches header value
function doubleSubmitCheck(req: Request, res: Response, next: NextFunction) {
const cookieValue = req.cookies.csrf;
const headerValue = req.headers['x-csrf-token'];
if (!cookieValue || cookieValue !== headerValue) {
return res.status(403).json({ error: 'CSRF validation failed' });
}
next();
}
// Client: Read cookie and send as header
const csrfToken = document.cookie
.split('; ')
.find(c => c.startsWith('csrf='))
?.split('=')[1];
fetch('/api/transfer', {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken },
body: JSON.stringify({ amount: 100 }),
});
Framework-Specific CSRF Protection
Next.js Server Actions
Next.js Server Actions have built-in CSRF protection via the Next-Action header — but verify this is enforced in your version.
Django
# Built-in — just use the template tag
{% csrf_token %}
# For AJAX, read the csrftoken cookie and send as X-CSRFToken header
Express
import { doubleCsrf } from 'csrf-csrf';
const { doubleCsrfProtection, generateToken } = doubleCsrf({
getSecret: () => process.env.CSRF_SECRET!,
cookieName: '__csrf',
cookieOptions: { secure: true, sameSite: 'lax' },
});
app.use(doubleCsrfProtection);
CSRF Testing Checklist
- All state-changing endpoints require CSRF tokens
- Session cookies have
SameSite=LaxorStrict - No state-changing operations on GET requests
- CSRF tokens are random, unique per session, and verified server-side
- Custom headers required for API calls (
X-Requested-With,X-CSRF-Token) - Content-Type validation rejects unexpected
text/plainsubmissions - WebSocket connections verify origin header
Advertisement
Free Security Tools
Try our tools now
Expert Services
Get professional help
OWASP Top 10
Learn the top risks
Related Articles
Threat Modeling for Developers: STRIDE, PASTA & DREAD with Practical Examples
Threat modeling is the most cost-effective security activity — finding design flaws before writing code. This guide covers STRIDE, PASTA, and DREAD methodologies with real-world examples for web, API, and cloud applications.
Building a Security Champions Program: Scaling Security Across Dev Teams
Security teams can't review every line of code. Security Champions embed security expertise in every development team. This guide covers program design, champion selection, training, metrics, and sustaining engagement.
The Ultimate Secure Code Review Checklist for 2025
A comprehensive, language-agnostic checklist for secure code reviews. Use this as your team's standard for catching vulnerabilities before they reach production.