Skip to content

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

server.js
// 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

server.js
// 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

ProfileSettings.jsx
// 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

server.js
// 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 (requires cookie-parser), enabling horizontal scaling without session middleware
  • Defense-in-depth: sameSite: 'strict' blocks cross-site cookies before validation; secure: true ensures HTTPS-only; httpOnly: true for session cookies
  • Flexible validation: Checks _csrf form field or custom header; req.csrfToken() generates fresh tokens for JavaScript clients via /api/csrf-token endpoint
  • Edge case handling: Handles token expiration, rotation, and session store integration better than custom implementations

Express with custom token generation

csrf-middleware.js
// 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;
server.js
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-token header (AJAX) and _csrf body 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 csurf for production (handles token rotation, edge cases, session stores)

Fastify with CSRF plugin

server.js
// 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

api.js
// 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.js module fetches token lazily (first API call), caches it, and auto-includes X-CSRF-Token in all requests - components just call apiPost() 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

middleware.js
// 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*',
};
app/api/transfer/route.js
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-Token header won't be sent cross-site; withCredentials: true sends 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
doubleSubmitCsrf.js
// 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;
server.js
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 using CSRF_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: false for JavaScript access, secure: true for 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

Additional Resources