How to Implement CSRF Protection
Prevent unauthorized actions from malicious websites
TL;DR
TL;DR
Use SameSite=Lax or Strict cookies for session tokens. For APIs using cookies, add CSRF tokens to forms and verify them server-side. If you use Bearer tokens in Authorization headers (not cookies), you likely don't need CSRF protection.
What is CSRF?
Cross-Site Request Forgery happens when a malicious website tricks your browser into making requests to another site where you're logged in. Because cookies are sent automatically, the malicious request appears legitimate.
<!-- Malicious website has this hidden form -->
<form action="https://yourbank.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>
<!-- If you're logged into yourbank.com, your session cookie
is sent automatically with this request! -->
Do You Need CSRF Protection?
You DON'T need CSRF tokens if:
- You use Bearer tokens in Authorization headers (not cookies)
- Your API only accepts JSON with proper Content-Type headers
- You use SameSite=Strict cookies
You DO need CSRF protection if:
- You use session cookies for authentication
- Your forms use traditional form submissions
- You need to support older browsers without SameSite support
Method 1: SameSite Cookies (Simplest)
The easiest protection is using SameSite cookies. This prevents cookies from being sent on cross-origin requests.
// Set cookie with SameSite attribute
res.cookie('session', sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax', // or 'strict' for maximum protection
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
// SameSite options:
// 'strict' - Cookie only sent for same-site requests
// 'lax' - Cookie sent for same-site + top-level navigations (links)
// 'none' - Cookie sent for all requests (requires secure: true)
Method 2: CSRF Tokens (Traditional)
Generate a random token, store it in the session, and require it in forms.
Generate and verify tokens
import Tokens from 'csrf';
const tokens = new Tokens();
// Generate a secret (store in session)
const secret = tokens.secretSync();
// Generate a token for the form
const csrfToken = tokens.create(secret);
// Verify the token
const isValid = tokens.verify(secret, submittedToken);
Include token in forms
<form action="/api/transfer" method="POST">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<input type="text" name="amount">
<button type="submit">Transfer</button>
</form>
Next.js Implementation
Server Actions (App Router)
Next.js Server Actions have built-in CSRF protection. The framework validates that the request came from your application.
// This is protected automatically
'use server';
export async function transferMoney(formData: FormData) {
const amount = formData.get('amount');
// Process transfer
}
API Routes with Cookies
// lib/csrf.ts
import Tokens from 'csrf';
import { cookies } from 'next/headers';
const tokens = new Tokens();
export function generateCsrfToken() {
const secret = tokens.secretSync();
const token = tokens.create(secret);
// Store secret in cookie
cookies().set('csrf-secret', secret, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
});
return token;
}
export function verifyCsrfToken(token: string): boolean {
const secret = cookies().get('csrf-secret')?.value;
if (!secret) return false;
return tokens.verify(secret, token);
}
// app/api/transfer/route.ts
import { verifyCsrfToken } from '@/lib/csrf';
export async function POST(request: Request) {
const body = await request.json();
if (!verifyCsrfToken(body._csrf)) {
return Response.json(
{ error: 'Invalid CSRF token' },
{ status: 403 }
);
}
// Process the request
}
Express.js Implementation
import express from 'express';
import csrf from 'csurf';
import cookieParser from 'cookie-parser';
const app = express();
app.use(cookieParser());
app.use(express.urlencoded({ extended: false }));
// Enable CSRF protection
const csrfProtection = csrf({ cookie: true });
// Apply to state-changing routes
app.post('/transfer', csrfProtection, (req, res) => {
// Token is verified automatically
// If invalid, middleware returns 403
res.json({ success: true });
});
// Provide token to forms
app.get('/form', csrfProtection, (req, res) => {
res.render('form', { csrfToken: req.csrfToken() });
});
Double Submit Cookie Pattern
An alternative that doesn't require server-side session storage.
// Set CSRF cookie (readable by JavaScript)
res.cookie('csrf-token', token, {
httpOnly: false, // Must be readable by JS
secure: true,
sameSite: 'strict',
});
// Client reads cookie and sends in header
fetch('/api/transfer', {
method: 'POST',
headers: {
'X-CSRF-Token': getCookie('csrf-token'),
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
// Server compares cookie value with header value
const cookieToken = req.cookies['csrf-token'];
const headerToken = req.headers['x-csrf-token'];
if (cookieToken !== headerToken) {
return res.status(403).json({ error: 'CSRF validation failed' });
}
For SPAs with JSON APIs
If your API only accepts JSON and uses proper CORS, you get protection automatically:
// Only accept JSON content type
app.use((req, res, next) => {
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
const contentType = req.headers['content-type'];
if (!contentType?.includes('application/json')) {
return res.status(415).json({ error: 'Content-Type must be application/json' });
}
}
next();
});
// Combined with SameSite cookies, this blocks CSRF attacks
// because browsers can't send JSON with custom Content-Type cross-origin
Testing CSRF Protection
# Test without CSRF token (should fail)
curl -X POST http://localhost:3000/api/transfer \
-H "Cookie: session=your-session-cookie" \
-d "amount=100"
# Expected: 403 Forbidden
# Test with CSRF token (should succeed)
curl -X POST http://localhost:3000/api/transfer \
-H "Cookie: session=your-session-cookie" \
-H "X-CSRF-Token: valid-token" \
-d "amount=100"
# Expected: 200 OK
Quick Reference
| Auth Method | CSRF Risk | Protection |
|---|---|---|
| Cookie sessions | High | SameSite + CSRF tokens |
| Bearer tokens (localStorage) | None | Not needed (but XSS risk) |
| Bearer tokens (httpOnly cookie) | High | SameSite + CSRF tokens |