CWE-352: Cross-Site Request Forgery (CSRF) - JavaScript/Node.js
Overview
CSRF vulnerabilities in JavaScript/Node.js applications occur when state-changing endpoints don't verify that requests originated from the application itself. Both server-side (Express, Fastify) and client-side (React, Vue, Angular) implementations require proper CSRF protection.
Primary Defence: Use csurf middleware (Express) or @fastify/csrf-protection (Fastify) to generate and validate CSRF tokens, and include tokens in forms or AJAX request headers.
Common Vulnerable Patterns
Express without CSRF protection
// VULNERABLE
const express = require('express');
const session = require('express-session');
const bodyParser = require('body-parser');
const app = express();
app.use(session({
secret: 'session-secret',
resave: false,
saveUninitialized: false
}));
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
// VULNERABLE - No CSRF protection
app.post('/api/transfer', (req, res) => {
if (!req.session.userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { toAccount, amount } = req.body;
// Transfer funds without CSRF validation
transferService.transfer(req.session.userId, toAccount, amount);
res.json({ status: 'success' });
});
app.post('/api/delete-account', (req, res) => {
// VULNERABLE - Critical action without CSRF token
if (req.session.userId) {
userService.deleteAccount(req.session.userId);
req.session.destroy();
}
res.json({ status: 'deleted' });
});
Why this is vulnerable: Without csurf middleware, Express endpoints accept any POST request with valid session cookies, allowing attackers to embed malicious forms in external websites that perform unauthorized transfers, account deletions, or other state changes when victims are logged in.
Fastify without CSRF protection
// VULNERABLE
const fastify = require('fastify')();
const fastifySession = require('@fastify/session');
const fastifyCookie = require('@fastify/cookie');
fastify.register(fastifyCookie);
fastify.register(fastifySession, {
secret: 'session-secret',
cookie: { secure: true }
});
// VULNERABLE - No CSRF validation
fastify.post('/api/update-email', async (request, reply) => {
if (!request.session.userId) {
return reply.code(401).send({ error: 'Unauthorized' });
}
const { email } = request.body;
await userService.updateEmail(request.session.userId, email);
return { status: 'updated' };
});
Why this is vulnerable: Fastify endpoints without @fastify/csrf-protection accept authenticated requests from any origin, enabling CSRF attacks where malicious websites submit forms or AJAX requests using victims' session cookies to change emails, transfer funds, or modify account settings.
React fetch without CSRF token
// VULNERABLE
import React, { useState } from 'react';
function ProfileSettings() {
const [email, setEmail] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
// VULNERABLE - No CSRF token included
const response = await fetch('/api/update-email', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include', // Sends cookies but no CSRF token
body: JSON.stringify({ email })
});
if (response.ok) {
alert('Email updated');
}
};
return (
Why this is vulnerable: React applications using fetch with credentials: 'include' send session cookies but no CSRF token, allowing attackers to create malicious websites that submit identical requests using victims' authenticated sessions to change emails, transfer funds, or perform other unauthorized actions.
Secure Patterns
Express with csurf middleware
// SECURE
const express = require('express');
const session = require('express-session');
const csrf = require('csurf');
const cookieParser = require('cookie-parser');
const app = express();
// Session configuration
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // HTTPS only
httpOnly: true, // No JavaScript access
sameSite: 'strict', // Prevent CSRF
maxAge: 3600000 // 1 hour
}
}));
app.use(cookieParser());
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
// CSRF protection for all routes
const csrfProtection = csrf({ cookie: true });
// Apply CSRF protection to state-changing routes
app.post('/api/transfer', csrfProtection, (req, res) => {
if (!req.session.userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
// CSRF token automatically validated by middleware
const { toAccount, amount } = req.body;
transferService.transfer(req.session.userId, toAccount, amount)
.then(() => res.json({ status: 'success' }))
.catch(err => res.status(500).json({ error: err.message }));
});
app.post('/api/delete-account', csrfProtection, (req, res) => {
// CSRF validation automatic
if (req.session.userId) {
userService.deleteAccount(req.session.userId);
req.session.destroy();
}
res.json({ status: 'deleted' });
});
// Endpoint to get CSRF token for AJAX requests
app.get('/api/csrf-token', csrfProtection, (req, res) => {
res.json({ csrfToken: req.csrfToken() });
});
// Render form with CSRF token
app.get('/transfer', csrfProtection, (req, res) => {
res.render('transfer', { csrfToken: req.csrfToken() });
});
Why this works:
- Industry-standard protection: Battle-tested middleware generates cryptographically strong tokens using
crypto.randomBytes()with constant-time validation - Stateless operation:
csrf({ cookie: true })stores tokens in cookies (requirescookie-parser), enabling horizontal scaling without session middleware - Defense-in-depth:
sameSite: 'strict'blocks cross-site cookies before validation;secure: trueensures HTTPS-only;httpOnly: truefor session cookies - Flexible validation: Checks
_csrfform field or custom header;req.csrfToken()generates fresh tokens for JavaScript clients via/api/csrf-tokenendpoint - Edge case handling: Handles token expiration, rotation, and session store integration better than custom implementations
Express with custom token generation
// SECURE custom implementation
const crypto = require('crypto');
function generateToken() {
return crypto.randomBytes(32).toString('hex');
}
function csrfProtection(req, res, next) {
const method = req.method;
// Generate token for session if not exists
if (!req.session.csrfToken) {
req.session.csrfToken = generateToken();
}
// Validate token for state-changing methods
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) {
const sessionToken = req.session.csrfToken;
const requestToken = req.headers['x-csrf-token'] || req.body._csrf;
if (!requestToken || !crypto.timingSafeEqual(
Buffer.from(sessionToken),
Buffer.from(requestToken)
)) {
return res.status(403).json({ error: 'CSRF token validation failed' });
}
}
// Provide token getter function
req.csrfToken = () => req.session.csrfToken;
next();
}
module.exports = csrfProtection;
const csrfProtection = require('./csrf-middleware');
app.use(csrfProtection);
app.post('/api/transfer', (req, res) => {
// CSRF already validated by middleware
const { toAccount, amount } = req.body;
transferService.transfer(req.session.userId, toAccount, amount);
res.json({ status: 'success' });
});
Why this works:
- Cryptographic tokens:
crypto.randomBytes(32)generates 256-bit tokens (2^256 combinations) preventing brute-force; session binding ensures expiration and prevents cross-user reuse - Timing-safe validation:
crypto.timingSafeEqual()provides constant-time comparison unlike===, preventing attackers from inferring bytes through timing measurements - Hybrid support: Validates state-changing methods (POST/PUT/DELETE/PATCH) checking both
x-csrf-tokenheader (AJAX) and_csrfbody field (forms) - Proper byte comparison:
Buffer.from()ensures correct byte-level comparison avoiding JavaScript string encoding issues - Production note: Educational implementation - use battle-tested libraries like
csurffor production (handles token rotation, edge cases, session stores)
Fastify with CSRF plugin
// SECURE
const fastify = require('fastify')();
const fastifySession = require('@fastify/session');
const fastifyCookie = require('@fastify/cookie');
const fastifyCsrf = require('@fastify/csrf-protection');
fastify.register(fastifyCookie);
fastify.register(fastifySession, {
secret: process.env.SESSION_SECRET,
cookie: {
secure: true,
httpOnly: true,
sameSite: 'strict'
}
});
// Enable CSRF protection
fastify.register(fastifyCsrf, {
sessionPlugin: '@fastify/session',
cookieOpts: { signed: true }
});
// Protected route with CSRF
fastify.post('/api/update-email', async (request, reply) => {
if (!request.session.userId) {
return reply.code(401).send({ error: 'Unauthorized' });
}
// CSRF token automatically validated
const { email } = request.body;
await userService.updateEmail(request.session.userId, email);
return { status: 'updated' };
});
// Get CSRF token for client
fastify.get('/api/csrf-token', async (request, reply) => {
const token = await reply.generateCsrf();
return { csrfToken: token };
});
Why this works:
- Native integration: Plugin integrates with Fastify's lifecycle hooks for automatic validation before route handlers, rejecting invalid tokens with 403
- Session binding:
sessionPlugin: '@fastify/session'ties tokens to authenticated users;cookieOpts: { signed: true }uses HMAC to prevent cookie tampering - Performance-oriented: Fastify's precompiled schema validation handles CSRF efficiently for high-throughput APIs with minimal latency overhead
- Flexible scoping: Register globally or within specific plugins for fine-grained control in microservice architectures
- Tradeoff: Plugin doesn't support SameSite attributes directly; requires additional cookie configuration for defense-in-depth
React with CSRF token
// Utility for CSRF-protected requests
let csrfToken = null;
async function getCsrfToken() {
if (!csrfToken) {
const response = await fetch('/api/csrf-token', {
credentials: 'include'
});
const data = await response.json();
csrfToken = data.csrfToken;
}
return csrfToken;
}
export async function apiPost(url, body) {
const token = await getCsrfToken();
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': token
},
credentials: 'include',
body: JSON.stringify(body)
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json();
}
// ProfileSettings.jsx - SECURE
import React, { useState } from 'react';
import { apiPost } from './api';
function ProfileSettings() {
const [email, setEmail] = useState('');
const [error, setError] = useState(null);
const handleSubmit = async (e) => {
e.preventDefault();
setError(null);
try {
// CSRF token automatically included
await apiPost('/api/update-email', { email });
alert('Email updated successfully');
} catch (err) {
setError(err.message);
}
};
return (
<form onSubmit={handleSubmit}>
{error && <div className="error">{error}</div>}
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<button type="submit">Update Email</button>
</form>
);
}
export default ProfileSettings;
Why this works:
- Centralized security:
api.jsmodule fetches token lazily (first API call), caches it, and auto-includesX-CSRF-Tokenin all requests - components just callapiPost()without CSRF awareness - Same-origin protection: Custom header provides CSRF defense;
credentials: 'include'sends auth cookies while browsers block cross-site header injection - Scalability: Single source of truth for hundreds of components; changing token mechanism requires updating only
api.js, not every component - Testing simplification: Components receive mocked API functions without needing to mock token logic or HTTP requests
- XSS dependency: Requires XSS prevention (CSP, sanitization) since injected JavaScript can call
getCsrfToken()and make authenticated requests
Next.js with CSRF protection
// SECURE
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import crypto from 'crypto';
export function middleware(request) {
const cookieStore = cookies();
// Generate CSRF token if not exists
if (!cookieStore.get('csrf-token')) {
const token = crypto.randomBytes(32).toString('hex');
const response = NextResponse.next();
response.cookies.set('csrf-token', token, {
httpOnly: false, // JavaScript needs to read
secure: true,
sameSite: 'strict',
path: '/'
});
return response;
}
// Validate CSRF for state-changing methods
const method = request.method;
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) {
const cookieToken = cookieStore.get('csrf-token')?.value;
const headerToken = request.headers.get('x-csrf-token');
if (!cookieToken || cookieToken !== headerToken) {
return new NextResponse(
JSON.stringify({ error: 'CSRF token validation failed' }),
{ status: 403, headers: { 'content-type': 'application/json' } }
);
}
}
return NextResponse.next();
}
export const config = {
matcher: '/api/:path*',
};
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
export async function POST(request) {
// CSRF validated by middleware
const session = cookies().get('session');
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { toAccount, amount } = await request.json();
await transferService.transfer(session.userId, toAccount, amount);
return NextResponse.json({ status: 'success' });
}
```javascript tile="components/TransferForm.jsx" 'use client';
import { useState } from 'react';
export default function TransferForm() { const [formData, setFormData] = useState({ toAccount: '', amount: '' });
const getCsrfToken = () => {
const cookies = document.cookie.split(';');
const csrfCookie = cookies.find(c => c.trim().startsWith('csrf-token='));
return csrfCookie ? csrfCookie.split('=')[1] : null;
};
const handleSubmit = async (e) => {
e.preventDefault();
const response = await fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfToken()
},
body: JSON.stringify(formData)
});
if (response.ok) {
alert('Transfer successful');
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
placeholder="To Account"
value={formData.toAccount}
onChange={(e) => setFormData({...formData, toAccount: e.target.value})}
required
/>
<input
type="number"
placeholder="Amount"
value={formData.amount}
onChange={(e) => setFormData({...formData, amount: e.target.value})}
required
/>
<button type="submit">Transfer</button>
</form>
);
}
``
**Why this works:**
- **Edge-level protection:** Middleware runs before API routes, validating CSRF tokens efficiently without adding latency to route handlers
- **Automatic coverage:**/api/:pathmatcher validates all state-changing requests (POST/PUT/DELETE/PATCH) centrally without per-route configuration
- **Cryptographic tokens:**crypto.randomBytes(32)generates 256-bit tokens stored in cookies withsecure: trueandsameSite: 'strict'for defense-in-depth
- **Hybrid rendering support:** Works with Next.js Server Components and client-side JavaScript -getCsrfToken()` extracts cookie value for fetch requests
- Horizontal scaling:* Cookie-based (no sessions) enables load balancing without session affinity; requires cookie encryption key protection and rotation
Axios interceptor with CSRF
javascript title="axiosConfig.js"
// SECURE
import axios from 'axios';
// Get CSRF token from cookie
function getCsrfToken() {
const name = 'csrf-token';
const cookies = document.cookie.split(';');
for (let cookie of cookies) {
const [cookieName, cookieValue] = cookie.trim().split('=');
if (cookieName === name) {
return decodeURIComponent(cookieValue);
}
}
return null;
}
// Create axios instance with CSRF protection
const api = axios.create({
baseURL: '/api',
withCredentials: true
});
// Add CSRF token to all requests
api.interceptors.request.use(
(config) => {
// Only add CSRF token for state-changing methods
if (['post', 'put', 'delete', 'patch'].includes(config.method)) {
const csrfToken = getCsrfToken();
if (csrfToken) {
config.headers['X-CSRF-Token'] = csrfToken;
}
}
return config;
},
(error) => Promise.reject(error)
);
// Handle CSRF errors
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 403) {
// CSRF token might be expired, refresh and retry
console.error('CSRF validation failed');
}
return Promise.reject(error);
}
);
export default api;
// Usage in component
import api from './axiosConfig';
async function transferFunds(toAccount, amount) {
// CSRF token automatically included by interceptor
const response = await api.post('/transfer', {
toAccount,
amount
});
return response.data;
}
Why this works:
- Centralized automation: Request interceptor automatically injects CSRF tokens into all state-changing requests (POST/PUT/DELETE/PATCH) without per-request configuration
- Same-origin protection: Custom
X-CSRF-Tokenheader won't be sent cross-site;withCredentials: truesends auth cookies while header provides CSRF defense - Token rotation support: Re-reads from cookies on each request, automatically using new tokens after server rotation
- Application-wide policy: New endpoints inherit CSRF protection automatically; changes to token mechanism require modifying only interceptor, not every API call
- Graceful error handling: Response interceptor handles 403 errors for token failures, enabling refresh prompts or login redirects
Double Submit Cookie Pattern
// SECURE
const crypto = require('crypto');
function generateToken() {
return crypto.randomBytes(32).toString('hex');
}
function sign(value, secret) {
const hmac = crypto.createHmac('sha256', secret);
hmac.update(value);
return hmac.digest('hex');
}
function doubleSubmitCsrfMiddleware(secret) {
return (req, res, next) => {
// Generate token if not exists
if (!req.cookies['csrf-token']) {
const token = generateToken();
const signature = sign(token, secret);
res.cookie('csrf-token', `${token}.${signature}`, {
httpOnly: false,
secure: true,
sameSite: 'strict'
});
}
// Validate for state-changing methods
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
const cookieValue = req.cookies['csrf-token'];
const headerToken = req.headers['x-csrf-token'];
if (!cookieValue || !headerToken) {
return res.status(403).json({ error: 'CSRF token missing' });
}
const [cookieToken, signature] = cookieValue.split('.');
const expectedSignature = sign(cookieToken, secret);
// Verify signature
if (!crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
)) {
return res.status(403).json({ error: 'CSRF token invalid' });
}
// Verify tokens match
if (!crypto.timingSafeEqual(
Buffer.from(cookieToken),
Buffer.from(headerToken)
)) {
return res.status(403).json({ error: 'CSRF token mismatch' });
}
}
next();
};
}
module.exports = doubleSubmitCsrfMiddleware;
const doubleSubmitCsrf = require('./doubleSubmitCsrf');
app.use(doubleSubmitCsrf(process.env.CSRF_SECRET));
Why this works:
- Stateless scaling: No server sessions needed - tokens in cookies only, enabling horizontal scaling for load-balanced deployments
- HMAC signature security:
crypto.randomBytes(32)generates 256-bit tokens signed with HMAC-SHA256 usingCSRF_SECRET, preventing forgery without the secret - Dual validation: Verifies signature using
crypto.timingSafeEqual()(constant-time) and matches cookie/header - attackers can't read cookies (same-origin) or set custom headers cross-site - Cookie security:
httpOnly: falsefor JavaScript access,secure: truefor HTTPS-only,sameSite: 'strict'for cross-site blocking - Hybrid support: Works for forms (token in hidden field) and AJAX (token in header)
- Tradeoffs: Requires secret management across instances, rotation policies, and lifecycle management since tokens don't auto-expire on logout
Verification
After implementing the recommended secure patterns, verify the fix through multiple approaches:
- Manual testing: Submit malicious payloads relevant to this vulnerability and confirm they're handled safely without executing unintended operations
- Code review: Confirm all instances use the secure pattern (parameterized queries, safe APIs, proper encoding) with no string concatenation or unsafe operations
- Static analysis: Use security scanners to verify no new vulnerabilities exist and the original finding is resolved
- Regression testing: Ensure legitimate user inputs and application workflows continue to function correctly
- Edge case validation: Test with special characters, boundary conditions, and unusual inputs to verify proper handling
- Framework verification: If using a framework or library, confirm the recommended APIs are used correctly according to documentation
- Authentication/session testing: Verify security controls remain effective and cannot be bypassed (if applicable to the vulnerability type)
- Rescan: Run the security scanner again to confirm the finding is resolved and no new issues were introduced