jsmanifest logojsmanifest

CSRF Protection: How to Secure Your Node.js API

CSRF Protection: How to Secure Your Node.js API

Learn when your Node.js API actually needs CSRF protection and how to implement it correctly. Real-world examples of synchronizer tokens, custom middleware, and authentication patterns that work.

While I was looking over some security vulnerabilities in a production Node.js application the other day, I came across something that made me pause. The team had implemented CSRF protection on their REST API—but they were using JWT tokens stored in localStorage. In other words, they were protecting against an attack that couldn't even happen in their setup.

I was once guilty of the same thing. Little did I know that CSRF protection isn't a one-size-fits-all solution, and implementing it incorrectly can give you a false sense of security while adding unnecessary complexity to your codebase.

Understanding CSRF Attacks: What You're Up Against

Cross-Site Request Forgery attacks exploit the fact that browsers automatically include cookies with every request to a domain. Imagine you're logged into your banking app. A malicious website you visit could trigger a POST request to your bank's API to transfer money—and your browser would dutifully include your session cookie.

When I finally decided to dig into this properly, I realized the attack works because of three conditions:

  1. Your application uses cookies for authentication
  2. The attacker can predict the request parameters
  3. The browser automatically sends credentials with the request

Here's what surprised me: if you're building a modern API that stores tokens in localStorage and sends them via the Authorization header, you're already protected. The browser won't automatically include those tokens in requests initiated by other sites.

But if you're using session cookies or any cookie-based authentication, CSRF protection becomes critical.

When Your API Actually Needs CSRF Protection (And When It Doesn't)

I cannot stress this enough! Not every Node.js API needs CSRF protection, and implementing it when you don't need it is a waste of time and resources.

You need CSRF protection when:

  • Using session cookies for authentication
  • Using any cookie-based auth mechanism
  • Your API serves a traditional server-rendered application
  • Cookies are involved in state-changing operations

You don't need CSRF protection when:

  • Using JWT tokens exclusively in Authorization headers
  • Building a pure REST API with no browser-based clients
  • All authentication credentials are sent as HTTP headers (not cookies)

CSRF protection decision flow

The biggest mistake I see developers make is cargo-culting security measures without understanding the threat model. I spent weeks implementing CSRF protection on an API that didn't need it because everyone said it was "best practice."

Implementing CSRF Tokens with the Synchronizer Token Pattern

Luckily we can implement robust CSRF protection using the synchronizer token pattern. This is the most common and reliable approach for session-based applications.

Here's how it works: your server generates a unique token and associates it with the user's session. The client includes this token in subsequent requests, and the server validates it before processing state-changing operations.

import express from 'express';
import session from 'express-session';
import crypto from 'crypto';
 
const app = express();
 
// Session configuration
app.use(session({
  secret: 'your-secret-key',
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict'
  }
}));
 
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
 
// Generate CSRF token
function generateToken() {
  return crypto.randomBytes(32).toString('hex');
}
 
// Middleware to provide CSRF token
app.use((req, res, next) => {
  if (!req.session.csrfToken) {
    req.session.csrfToken = generateToken();
  }
  next();
});
 
// Endpoint to get CSRF token
app.get('/api/csrf-token', (req, res) => {
  res.json({ csrfToken: req.session.csrfToken });
});
 
// CSRF validation middleware
function validateCsrfToken(req, res, next) {
  const token = req.headers['x-csrf-token'] || req.body._csrf;
  
  if (!token || token !== req.session.csrfToken) {
    return res.status(403).json({ 
      error: 'Invalid CSRF token' 
    });
  }
  
  next();
}
 
// Protected route
app.post('/api/transfer-money', validateCsrfToken, (req, res) => {
  // Process the transfer
  res.json({ success: true });
});
 
app.listen(3000);

When I was working on a financial application last year, this pattern saved us from a potential breach. A phishing attempt tried to trigger fund transfers, but our CSRF validation caught it immediately.

The key here is that the token must be unpredictable and tied to the user's session. Wonderful! This means even if an attacker knows the endpoint and parameters, they can't forge a valid request without the token.

Building Custom CSRF Protection Middleware from Scratch

While libraries like csurf work great, I realized building my own middleware gave me better control and helped me understand the security model deeply. Here's a production-ready implementation I've used in several projects:

import { Request, Response, NextFunction } from 'express';
import crypto from 'crypto';
 
interface CsrfConfig {
  tokenLength?: number;
  headerName?: string;
  excludePaths?: string[];
  regenerateOnRequest?: boolean;
}
 
class CsrfProtection {
  private tokenLength: number;
  private headerName: string;
  private excludePaths: Set<string>;
  private regenerateOnRequest: boolean;
  
  constructor(config: CsrfConfig = {}) {
    this.tokenLength = config.tokenLength || 32;
    this.headerName = config.headerName || 'x-csrf-token';
    this.excludePaths = new Set(config.excludePaths || []);
    this.regenerateOnRequest = config.regenerateOnRequest || false;
  }
  
  generateToken(): string {
    return crypto.randomBytes(this.tokenLength).toString('base64');
  }
  
  middleware() {
    return (req: Request, res: Response, next: NextFunction) => {
      // Skip CSRF for safe methods
      if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
        return next();
      }
      
      // Skip excluded paths
      if (this.excludePaths.has(req.path)) {
        return next();
      }
      
      // Initialize token if needed
      if (!req.session?.csrfToken) {
        req.session.csrfToken = this.generateToken();
      }
      
      // Validate token for state-changing requests
      const clientToken = req.headers[this.headerName] as string || 
                         req.body?._csrf;
      
      if (!clientToken) {
        return res.status(403).json({
          error: 'CSRF token missing',
          message: 'Include CSRF token in request headers or body'
        });
      }
      
      // Constant-time comparison to prevent timing attacks
      const sessionToken = req.session.csrfToken;
      if (clientToken.length !== sessionToken.length) {
        return res.status(403).json({ error: 'Invalid CSRF token' });
      }
      
      const isValid = crypto.timingSafeEqual(
        Buffer.from(clientToken),
        Buffer.from(sessionToken)
      );
      
      if (!isValid) {
        return res.status(403).json({ error: 'Invalid CSRF token' });
      }
      
      // Optionally regenerate token after each request
      if (this.regenerateOnRequest) {
        req.session.csrfToken = this.generateToken();
      }
      
      next();
    };
  }
  
  tokenProvider() {
    return (req: Request, res: Response) => {
      if (!req.session?.csrfToken) {
        req.session.csrfToken = this.generateToken();
      }
      res.json({ csrfToken: req.session.csrfToken });
    };
  }
}
 
// Usage
const csrf = new CsrfProtection({
  excludePaths: ['/api/webhook', '/api/public'],
  regenerateOnRequest: false
});
 
app.use(csrf.middleware());
app.get('/api/csrf-token', csrf.tokenProvider());

Notice how I'm using crypto.timingSafeEqual for token comparison. This prevents timing attacks where an attacker could deduce the token by measuring how long validation takes. Fascinating how these small details make a huge difference in security!

CSRF middleware implementation diagram

Double Submit Cookie Pattern vs Synchronizer Tokens: A Practical Comparison

The double submit cookie pattern offers an alternative that doesn't require server-side session storage. You set a random token in a cookie, and the client sends that same token in a custom header.

Here's the key difference: synchronizer tokens store the token server-side, while double submit cookies rely on the same-origin policy. Both work, but they have different tradeoffs.

I prefer synchronizer tokens when I'm already using sessions because they're more secure. The double submit pattern is vulnerable if an attacker can inject a cookie into your domain through a subdomain vulnerability.

When I was working on a microservices architecture, however, the double submit pattern made more sense. We didn't want to maintain session state across services, and the stateless nature of double submit cookies fit perfectly with our JWT-based auth system.

The ROI on learning both patterns is significant. You'll make better architectural decisions when you understand the security implications of each approach.

CSRF Protection for Modern Authentication: JWT, Sessions, and SameSite Cookies

In other words, your authentication strategy determines your CSRF protection needs. Let me break down what I've learned works in production:

For JWT tokens in localStorage or sessionStorage, you don't need CSRF protection at all. The browser won't automatically include these tokens, so cross-site requests fail by default.

For session cookies, implement full CSRF protection with synchronizer tokens. This is the traditional web application scenario where CSRF attacks are most dangerous.

For cookie-based JWT storage, you need CSRF protection plus the SameSite cookie attribute. Setting SameSite=Strict or SameSite=Lax provides excellent protection against CSRF in modern browsers.

The combination I recommend for 2026 is session cookies with SameSite=Strict plus synchronizer tokens. This gives you defense in depth—even if one protection fails, the other catches the attack.

Testing Your CSRF Defenses: Validation Strategies That Work

I cannot stress this enough! Testing your CSRF protection is just as important as implementing it. Here's what I do:

First, verify that requests without tokens are rejected. Make a POST request without the CSRF token and confirm you get a 403 response.

Second, test with invalid tokens. Send a random token and verify it's rejected. This confirms your validation logic works correctly.

Third, test token rotation if you're regenerating tokens. Make a request, get a new token, and verify the old token no longer works.

Finally, test your excluded paths. If you're skipping CSRF for certain endpoints (like webhooks), make sure those work without tokens while protected endpoints still require them.

When I finally decided to automate these tests, I caught several bugs in production code that manual testing had missed. The ROI on automated security testing is enormous.

CSRF Protection Checklist: Securing Your Node.js API in Production

Before you deploy, run through this checklist I wish I'd had when I started:

  • Determine if you actually need CSRF protection based on your auth strategy
  • Implement synchronizer tokens for session-based applications
  • Use constant-time comparison for token validation
  • Set SameSite cookie attributes appropriately
  • Exclude safe methods (GET, HEAD, OPTIONS) from CSRF checks
  • Provide a clear endpoint for clients to fetch tokens
  • Use HTTPS in production to prevent token theft
  • Implement proper error messages without leaking security details
  • Test all protected endpoints thoroughly
  • Document your CSRF implementation for your team

The biggest lesson I've learned is that CSRF protection isn't about following a recipe—it's about understanding your threat model and choosing the right tools for your specific situation.

And that concludes the end of this post! I hope you found this valuable and look out for more in the future!