Navigation

Node.js

How to Add CSRF Protection to Forms/APIs

Implement Cross-Site Request Forgery protection with tokens, double submit cookies, and SameSite attributes in Node.js 2025

Table Of Contents

Quick Fix: Basic CSRF Protection

const csrf = require('csurf');
const cookieParser = require('cookie-parser');

// Basic CSRF middleware setup
const express = require('express');
const app = express();

app.use(cookieParser());
app.use(express.urlencoded({ extended: true }));
app.use(express.json());

// Configure CSRF protection
const csrfProtection = csrf({
  cookie: {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict'
  }
});

// Apply CSRF protection to forms
app.use('/forms', csrfProtection);

// Generate CSRF token for forms
app.get('/csrf-token', csrfProtection, (req, res) => {
  res.json({ csrfToken: req.csrfToken() });
});

// Protected form endpoint
app.post('/forms/submit', csrfProtection, (req, res) => {
  res.json({ 
    message: 'Form submitted successfully',
    data: req.body 
  });
});

// Custom error handler for CSRF
app.use((err, req, res, next) => {
  if (err.code === 'EBADCSRFTOKEN') {
    res.status(403).json({
      error: 'Invalid CSRF token',
      code: 'CSRF_TOKEN_INVALID'
    });
  } else {
    next(err);
  }
});

The Problem: Comprehensive CSRF Protection System

const crypto = require('crypto');
const cookieParser = require('cookie-parser');

// Advanced CSRF protection service
class CSRFProtectionService {
  constructor(options = {}) {
    this.secret = options.secret || process.env.CSRF_SECRET || crypto.randomBytes(32).toString('hex');
    this.tokenLength = options.tokenLength || 32;
    this.saltLength = options.saltLength || 16;
    this.cookieName = options.cookieName || 'csrfToken';
    this.headerName = options.headerName || 'x-csrf-token';
    this.formFieldName = options.formFieldName || '_csrf';
    this.maxAge = options.maxAge || 60 * 60 * 1000; // 1 hour
    this.secure = options.secure !== false && process.env.NODE_ENV === 'production';
    this.sameSite = options.sameSite || 'strict';
    this.httpOnly = options.httpOnly !== false;
    this.skipMethods = options.skipMethods || ['GET', 'HEAD', 'OPTIONS'];
    this.trustedOrigins = options.trustedOrigins || [];
    this.enableDoubleSubmit = options.enableDoubleSubmit !== false;
    this.enableOriginCheck = options.enableOriginCheck !== false;
    this.enableRefererCheck = options.enableRefererCheck !== false;
  }

  // Generate CSRF token
  generateToken(sessionId = null) {
    const salt = crypto.randomBytes(this.saltLength).toString('hex');
    const hash = this.generateHash(salt, sessionId);
    
    return {
      token: `${salt}:${hash}`,
      salt,
      hash
    };
  }

  // Generate hash for token validation
  generateHash(salt, sessionId = null) {
    const data = sessionId ? `${salt}:${sessionId}` : salt;
    
    return crypto
      .createHmac('sha256', this.secret)
      .update(data)
      .digest('hex');
  }

  // Verify CSRF token
  verifyToken(token, sessionId = null) {
    if (!token || typeof token !== 'string') {
      return false;
    }

    const parts = token.split(':');
    if (parts.length !== 2) {
      return false;
    }

    const [salt, hash] = parts;
    const expectedHash = this.generateHash(salt, sessionId);
    
    // Use constant-time comparison to prevent timing attacks
    return this.constantTimeCompare(hash, expectedHash);
  }

  // Constant-time string comparison
  constantTimeCompare(a, b) {
    if (a.length !== b.length) {
      return false;
    }

    let result = 0;
    for (let i = 0; i < a.length; i++) {
      result |= a.charCodeAt(i) ^ b.charCodeAt(i);
    }
    
    return result === 0;
  }

  // Double submit cookie protection
  generateDoubleSubmitToken() {
    return crypto.randomBytes(this.tokenLength).toString('hex');
  }

  // Verify double submit token
  verifyDoubleSubmitToken(cookieToken, headerToken) {
    if (!cookieToken || !headerToken) {
      return false;
    }
    
    return this.constantTimeCompare(cookieToken, headerToken);
  }

  // Create comprehensive CSRF middleware
  createMiddleware(options = {}) {
    const {
      ignorePaths = [],
      customValidator = null,
      onViolation = null,
      useSession = true
    } = options;

    return (req, res, next) => {
      try {
        // Skip CSRF protection for certain methods
        if (this.skipMethods.includes(req.method)) {
          return next();
        }

        // Skip for ignored paths
        if (ignorePaths.some(path => req.path.startsWith(path))) {
          return next();
        }

        // Origin validation
        if (this.enableOriginCheck && !this.validateOrigin(req)) {
          this.handleViolation(req, res, 'Invalid origin', 'INVALID_ORIGIN', onViolation);
          return;
        }

        // Referer validation
        if (this.enableRefererCheck && !this.validateReferer(req)) {
          this.handleViolation(req, res, 'Invalid referer', 'INVALID_REFERER', onViolation);
          return;
        }

        // Get session ID if using sessions
        const sessionId = useSession ? req.sessionID || req.session?.id : null;

        // Double submit cookie protection
        if (this.enableDoubleSubmit) {
          const cookieToken = req.cookies[this.cookieName];
          const headerToken = req.headers[this.headerName] || req.body[this.formFieldName];

          if (!this.verifyDoubleSubmitToken(cookieToken, headerToken)) {
            this.handleViolation(req, res, 'Invalid CSRF token', 'CSRF_TOKEN_MISMATCH', onViolation);
            return;
          }
        } else {
          // Traditional CSRF token validation
          const token = req.headers[this.headerName] || req.body[this.formFieldName];

          if (!this.verifyToken(token, sessionId)) {
            this.handleViolation(req, res, 'Invalid CSRF token', 'CSRF_TOKEN_INVALID', onViolation);
            return;
          }
        }

        // Custom validation
        if (customValidator && !customValidator(req)) {
          this.handleViolation(req, res, 'Custom CSRF validation failed', 'CUSTOM_VALIDATION_FAILED', onViolation);
          return;
        }

        next();
      } catch (error) {
        console.error('CSRF middleware error:', error);
        res.status(500).json({
          error: 'CSRF validation failed',
          code: 'CSRF_ERROR'
        });
      }
    };
  }

  // Token generation middleware
  createTokenMiddleware(options = {}) {
    const { useSession = true, autoSet = true } = options;

    return (req, res, next) => {
      try {
        const sessionId = useSession ? req.sessionID || req.session?.id : null;

        if (this.enableDoubleSubmit) {
          // Generate double submit token
          const token = this.generateDoubleSubmitToken();
          
          if (autoSet) {
            res.cookie(this.cookieName, token, {
              httpOnly: false, // Must be accessible to JavaScript
              secure: this.secure,
              sameSite: this.sameSite,
              maxAge: this.maxAge
            });
          }

          req.csrfToken = () => token;
        } else {
          // Generate traditional CSRF token
          const tokenData = this.generateToken(sessionId);
          
          if (autoSet) {
            res.cookie(this.cookieName, tokenData.token, {
              httpOnly: this.httpOnly,
              secure: this.secure,
              sameSite: this.sameSite,
              maxAge: this.maxAge
            });
          }

          req.csrfToken = () => tokenData.token;
        }

        next();
      } catch (error) {
        console.error('CSRF token middleware error:', error);
        next(error);
      }
    };
  }

  // Validate request origin
  validateOrigin(req) {
    const origin = req.headers.origin;
    const host = req.headers.host;

    if (!origin) {
      // For same-origin requests, origin might not be present
      return true;
    }

    // Check if origin matches host
    const originHost = new URL(origin).host;
    if (originHost === host) {
      return true;
    }

    // Check trusted origins
    return this.trustedOrigins.some(trustedOrigin => {
      if (typeof trustedOrigin === 'string') {
        return origin === trustedOrigin;
      }
      if (trustedOrigin instanceof RegExp) {
        return trustedOrigin.test(origin);
      }
      return false;
    });
  }

  // Validate request referer
  validateReferer(req) {
    const referer = req.headers.referer;
    const host = req.headers.host;

    if (!referer) {
      // Some legitimate requests might not have referer
      return true;
    }

    try {
      const refererHost = new URL(referer).host;
      return refererHost === host;
    } catch (error) {
      return false;
    }
  }

  // Handle CSRF violations
  handleViolation(req, res, message, code, onViolation) {
    const violationData = {
      ip: req.ip,
      userAgent: req.headers['user-agent'],
      path: req.path,
      method: req.method,
      headers: req.headers,
      timestamp: new Date(),
      code
    };

    // Log violation
    console.warn('CSRF violation detected:', violationData);

    // Custom violation handler
    if (onViolation) {
      onViolation(violationData, req, res);
    }

    // Send response
    if (req.accepts('json')) {
      res.status(403).json({
        error: message,
        code
      });
    } else {
      res.status(403).send(`
        <!DOCTYPE html>
        <html>
        <head><title>Security Error</title></head>
        <body>
          <h1>Security Error</h1>
          <p>Your request could not be processed due to security restrictions.</p>
          <p>Error Code: ${code}</p>
        </body>
        </html>
      `);
    }
  }
}

// SameSite cookie configuration helper
class SameSiteCookieHelper {
  static configureSameSite(app, options = {}) {
    const {
      defaultSameSite = 'strict',
      secureCookies = process.env.NODE_ENV === 'production',
      cookiePrefix = '__Host-'
    } = options;

    // Override Express cookie settings
    app.use((req, res, next) => {
      const originalCookie = res.cookie;
      
      res.cookie = function(name, value, options = {}) {
        // Apply security defaults
        const secureOptions = {
          httpOnly: options.httpOnly !== false,
          secure: options.secure !== false && secureCookies,
          sameSite: options.sameSite || defaultSameSite,
          ...options
        };

        // Add secure prefix for production
        if (secureCookies && secureOptions.secure && secureOptions.httpOnly) {
          name = name.startsWith(cookiePrefix) ? name : cookiePrefix + name;
        }

        return originalCookie.call(this, name, value, secureOptions);
      };

      next();
    });
  }
}

// Form CSRF helper for rendering templates
class CSRFFormHelper {
  static createHiddenInput(token) {
    return `<input type="hidden" name="_csrf" value="${token}">`;
  }

  static createMetaTag(token) {
    return `<meta name="csrf-token" content="${token}">`;
  }

  static getClientSideScript() {
    return `
      <script>
        // CSRF token handling for AJAX requests
        (function() {
          const token = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
          
          if (token) {
            // Add to all fetch requests
            const originalFetch = window.fetch;
            window.fetch = function(url, options = {}) {
              options.headers = options.headers || {};
              options.headers['X-CSRF-Token'] = token;
              return originalFetch(url, options);
            };

            // Add to jQuery AJAX if available
            if (window.jQuery) {
              jQuery.ajaxSetup({
                beforeSend: function(xhr) {
                  xhr.setRequestHeader('X-CSRF-Token', token);
                }
              });
            }
          }
        })();
      </script>
    `;
  }
}

// Express application with comprehensive CSRF protection
const express = require('express');
const session = require('express-session');
const app = express();

// Initialize CSRF protection service
const csrfService = new CSRFProtectionService({
  enableDoubleSubmit: true,
  enableOriginCheck: true,
  enableRefererCheck: true,
  trustedOrigins: ['https://trusted-domain.com'],
  maxAge: 60 * 60 * 1000 // 1 hour
});

// Configure SameSite cookies
SameSiteCookieHelper.configureSameSite(app, {
  defaultSameSite: 'strict',
  secureCookies: process.env.NODE_ENV === 'production'
});

// Basic middleware
app.use(cookieParser());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// Session configuration
app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: process.env.NODE_ENV === 'production',
    httpOnly: true,
    sameSite: 'strict',
    maxAge: 24 * 60 * 60 * 1000 // 24 hours
  }
}));

// Generate CSRF token for all requests
app.use(csrfService.createTokenMiddleware({ autoSet: true }));

// CSRF token endpoint
app.get('/csrf-token', (req, res) => {
  res.json({ 
    csrfToken: req.csrfToken(),
    expires: new Date(Date.now() + csrfService.maxAge).toISOString()
  });
});

// Form page with CSRF protection
app.get('/form', (req, res) => {
  const token = req.csrfToken();
  
  res.send(`
    <!DOCTYPE html>
    <html>
    <head>
      <title>CSRF Protected Form</title>
      ${CSRFFormHelper.createMetaTag(token)}
    </head>
    <body>
      <h1>Protected Form</h1>
      <form method="POST" action="/api/submit">
        ${CSRFFormHelper.createHiddenInput(token)}
        <input type="text" name="message" placeholder="Enter message" required>
        <button type="submit">Submit</button>
      </form>
      
      <h2>AJAX Example</h2>
      <button onclick="submitAjax()">Submit via AJAX</button>
      
      ${CSRFFormHelper.getClientSideScript()}
      
      <script>
        function submitAjax() {
          fetch('/api/ajax-submit', {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json'
            },
            body: JSON.stringify({ message: 'AJAX test' })
          })
          .then(response => response.json())
          .then(data => alert('AJAX success: ' + data.message))
          .catch(error => alert('Error: ' + error.message));
        }
      </script>
    </body>
    </html>
  `);
});

// API routes with CSRF protection
app.use('/api', csrfService.createMiddleware({
  ignorePaths: ['/api/public'],
  onViolation: (violationData, req, res) => {
    // Custom violation handling
    console.error('CSRF violation from:', violationData.ip);
    
    // Optional: Rate limit the IP or block temporarily
    // rateLimit.blockIP(violationData.ip);
  }
}));

// Protected form submission
app.post('/api/submit', (req, res) => {
  res.json({
    message: 'Form submitted successfully',
    data: req.body,
    timestamp: new Date().toISOString()
  });
});

// Protected AJAX endpoint
app.post('/api/ajax-submit', (req, res) => {
  res.json({
    message: 'AJAX request processed successfully',
    data: req.body
  });
});

// Public endpoint (no CSRF protection)
app.post('/api/public/webhook', (req, res) => {
  res.json({ message: 'Webhook processed' });
});

// File upload with CSRF protection
app.post('/api/upload', (req, res) => {
  res.json({ message: 'File upload successful' });
});

// API endpoint requiring custom validation
app.post('/api/sensitive', csrfService.createMiddleware({
  customValidator: (req) => {
    // Additional validation logic
    return req.headers['x-api-key'] === process.env.API_KEY;
  }
}), (req, res) => {
  res.json({ message: 'Sensitive operation completed' });
});

// Error handler
app.use((err, req, res, next) => {
  if (err.code === 'EBADCSRFTOKEN') {
    res.status(403).json({
      error: 'Invalid CSRF token',
      code: 'CSRF_TOKEN_INVALID'
    });
  } else {
    console.error('Server error:', err);
    res.status(500).json({
      error: 'Internal server error',
      code: 'INTERNAL_ERROR'
    });
  }
});

// Health check (no CSRF needed)
app.get('/health', (req, res) => {
  res.json({ status: 'healthy' });
});

module.exports = {
  CSRFProtectionService,
  SameSiteCookieHelper,
  CSRFFormHelper,
  app
};

CSRF protection solves "cross-site request forgery", "unauthorized state changes", and "session riding" issues. Implement token-based validation, double submit cookies, origin verification. Use SameSite attributes, validate referer headers, handle violations securely. Support both form submissions and AJAX requests with proper error handling. Alternative: SameSite cookies only, custom headers, framework-specific CSRF libraries.

Share this article

Add Comment

No comments yet. Be the first to comment!

More from Node.js