Back to blog

Rate Limiting and CSRF Protection in Node.js: A Production Guide

Implement production-grade rate limiting, CSRF protection, and security headers to protect your Node.js API from attacks.

#security#nodejs#backend#rate-limiting

Why Rate Limiting Matters

In the first week of Aegis2FA's alpha testing, I noticed something alarming: someone was hammering the login endpoint with 500+ requests per minute. Without rate limiting, this could have:

  • Enabled brute force attacks
  • Caused database overload
  • Racked up compute costs
  • Created a denial-of-service situation

Rate limiting became priority zero.

The Multi-Layer Approach

I implemented three layers of rate limiting:

Layer 1: Global Rate Limit

Protect against general abuse:

import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import Redis from 'ioredis';
 
const redis = new Redis(process.env.REDIS_URL);
 
const globalLimiter = rateLimit({
  store: new RedisStore({
    sendCommand: (...args: string[]) => redis.call(...args),
  }),
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // 100 requests per window
  message: {
    error: 'Too many requests from this IP, please try again later.',
  },
  standardHeaders: true, // Return rate limit info in headers
  legacyHeaders: false,
});
 
app.use(globalLimiter);

Layer 2: Endpoint-Specific Limits

Different endpoints need different limits:

// Strict limit for authentication
const authLimiter = rateLimit({
  store: new RedisStore({
    sendCommand: (...args: string[]) => redis.call(...args),
  }),
  windowMs: 15 * 60 * 1000,
  max: 5, // Only 5 attempts per 15 minutes
  skipSuccessfulRequests: true, // Don't count successful logins
  message: {
    error: 'Too many login attempts, please try again later.',
    retryAfter: 900, // seconds
  },
});
 
app.post('/api/auth/login', authLimiter, loginHandler);
 
// More lenient for read-only endpoints
const readLimiter = rateLimit({
  windowMs: 60 * 1000,
  max: 60, // 60 requests per minute
});
 
app.get('/api/users/me', readLimiter, getCurrentUser);

Layer 3: User-Specific Limits

Track limits per user account:

import { Request } from 'express';
 
interface AuthRequest extends Request {
  user?: { id: string };
}
 
function createUserLimiter(
  max: number,
  windowMs: number
) {
  return rateLimit({
    store: new RedisStore({
      sendCommand: (...args: string[]) => redis.call(...args),
    }),
    windowMs,
    max,
    keyGenerator: (req: AuthRequest) => {
      // Use user ID if authenticated, otherwise IP
      return req.user?.id || req.ip;
    },
    handler: (req, res) => {
      res.status(429).json({
        error: 'Account rate limit exceeded',
        message: 'You have made too many requests. Please wait before trying again.',
      });
    },
  });
}
 
const user2FALimiter = createUserLimiter(
  5,                  // 5 attempts
  15 * 60 * 1000      // per 15 minutes
);
 
app.post(
  '/api/auth/verify-2fa',
  authenticate,
  user2FALimiter,
  verify2FAHandler
);

CSRF Protection

Understanding CSRF

Cross-Site Request Forgery tricks a user's browser into making unwanted requests:

<!-- Attacker's malicious site -->
<form action="https://your-app.com/api/auth/change-password" method="POST">
  <input type="hidden" name="password" value="hacked123" />
</form>
<script>
  document.forms[0].submit();
</script>

If the user is logged in, this would succeed!

Solution: CSRF Tokens

import csrf from 'csurf';
import cookieParser from 'cookie-parser';
 
app.use(cookieParser());
 
const csrfProtection = csrf({
  cookie: {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
  },
});
 
// Generate CSRF token
app.get('/api/csrf-token', csrfProtection, (req, res) => {
  res.json({ csrfToken: req.csrfToken() });
});
 
// Protect state-changing endpoints
app.post('/api/auth/change-password', csrfProtection, changePasswordHandler);
app.delete('/api/users/me', csrfProtection, deleteAccountHandler);

Frontend Integration

// Fetch CSRF token on app load
async function getCsrfToken(): Promise<string> {
  const { data } = await axios.get('/api/csrf-token');
  return data.csrfToken;
}
 
// Include in requests
const csrfToken = await getCsrfToken();
 
await axios.post(
  '/api/auth/change-password',
  { newPassword: 'secure123' },
  {
    headers: {
      'X-CSRF-Token': csrfToken,
    },
  }
);

Security Headers

Helmet.js Configuration

import helmet from 'helmet';
 
app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        styleSrc: ["'self'", "'unsafe-inline'"],
        scriptSrc: ["'self'"],
        imgSrc: ["'self'", 'data:', 'https:'],
        connectSrc: ["'self'"],
        fontSrc: ["'self'"],
        objectSrc: ["'none'"],
        mediaSrc: ["'self'"],
        frameSrc: ["'none'"],
      },
    },
    crossOriginEmbedderPolicy: true,
    crossOriginOpenerPolicy: true,
    crossOriginResourcePolicy: { policy: 'cross-origin' },
    dnsPrefetchControl: true,
    frameguard: { action: 'deny' },
    hidePoweredBy: true,
    hsts: {
      maxAge: 31536000, // 1 year
      includeSubDomains: true,
      preload: true,
    },
    ieNoOpen: true,
    noSniff: true,
    originAgentCluster: true,
    permittedCrossDomainPolicies: false,
    referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
    xssFilter: true,
  })
);

Advanced Rate Limiting Patterns

Sliding Window with Redis

More accurate than fixed windows:

import Redis from 'ioredis';
 
export class SlidingWindowRateLimiter {
  constructor(
    private redis: Redis,
    private windowMs: number,
    private maxRequests: number
  ) {}
 
  async isAllowed(key: string): Promise<{
    allowed: boolean;
    remaining: number;
    resetAt: number;
  }> {
    const now = Date.now();
    const windowStart = now - this.windowMs;
 
    const pipeline = this.redis.pipeline();
 
    // Remove old entries
    pipeline.zremrangebyscore(key, 0, windowStart);
 
    // Count requests in window
    pipeline.zcard(key);
 
    // Add current request
    pipeline.zadd(key, now, `${now}-${Math.random()}`);
 
    // Set expiry
    pipeline.expire(key, Math.ceil(this.windowMs / 1000));
 
    const results = await pipeline.exec();
 
    if (!results) {
      throw new Error('Redis pipeline failed');
    }
 
    const count = results[1][1] as number;
 
    const allowed = count < this.maxRequests;
    const remaining = Math.max(0, this.maxRequests - count - 1);
    const resetAt = now + this.windowMs;
 
    return { allowed, remaining, resetAt };
  }
}
 
// Usage
const limiter = new SlidingWindowRateLimiter(
  redis,
  15 * 60 * 1000, // 15 minutes
  5               // 5 requests
);
 
app.post('/api/auth/login', async (req, res, next) => {
  const result = await limiter.isAllowed(`login:${req.ip}`);
 
  if (!result.allowed) {
    return res.status(429).json({
      error: 'Too many requests',
      retryAfter: Math.ceil((result.resetAt - Date.now()) / 1000),
    });
  }
 
  res.set({
    'X-RateLimit-Remaining': result.remaining.toString(),
    'X-RateLimit-Reset': new Date(result.resetAt).toISOString(),
  });
 
  next();
});

Adaptive Rate Limiting

Adjust limits based on behavior:

interface UserRiskScore {
  score: number; // 0-100
  factors: {
    failedLogins: number;
    suspiciousIPs: boolean;
    recentBlocks: number;
  };
}
 
async function calculateRiskScore(userId: string): Promise<UserRiskScore> {
  const [failedLogins, suspiciousIPs, recentBlocks] = await Promise.all([
    redis.get(`failed-logins:${userId}`),
    redis.sismember('suspicious-ips', userId),
    redis.get(`blocks:${userId}`),
  ]);
 
  const score =
    parseInt(failedLogins || '0') * 10 +
    (suspiciousIPs ? 30 : 0) +
    parseInt(recentBlocks || '0') * 15;
 
  return {
    score: Math.min(100, score),
    factors: {
      failedLogins: parseInt(failedLogins || '0'),
      suspiciousIPs: Boolean(suspiciousIPs),
      recentBlocks: parseInt(recentBlocks || '0'),
    },
  };
}
 
function createAdaptiveLimiter() {
  return async (req: AuthRequest, res: Response, next: NextFunction) => {
    if (!req.user) return next();
 
    const risk = await calculateRiskScore(req.user.id);
 
    // Adjust limits based on risk
    let maxRequests: number;
    if (risk.score > 70) {
      maxRequests = 3; // High risk: strict limit
    } else if (risk.score > 40) {
      maxRequests = 5; // Medium risk: normal limit
    } else {
      maxRequests = 10; // Low risk: relaxed limit
    }
 
    const limiter = new SlidingWindowRateLimiter(
      redis,
      15 * 60 * 1000,
      maxRequests
    );
 
    const result = await limiter.isAllowed(`adaptive:${req.user.id}`);
 
    if (!result.allowed) {
      return res.status(429).json({
        error: 'Rate limit exceeded',
        riskScore: risk.score,
      });
    }
 
    next();
  };
}

Monitoring and Alerts

Track Rate Limit Violations

import { EventEmitter } from 'events';
 
const securityEvents = new EventEmitter();
 
function createMonitoredLimiter(name: string, limiter: RateLimitRequestHandler) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const originalStatus = res.status.bind(res);
 
    res.status = function (code: number) {
      if (code === 429) {
        // Rate limit hit
        securityEvents.emit('rate-limit-exceeded', {
          limiter: name,
          ip: req.ip,
          user: (req as AuthRequest).user?.id,
          endpoint: req.path,
          timestamp: new Date(),
        });
      }
      return originalStatus(code);
    };
 
    return limiter(req, res, next);
  };
}
 
// Set up monitoring
securityEvents.on('rate-limit-exceeded', async (event) => {
  console.warn('Rate limit exceeded:', event);
 
  // Track in Redis
  await redis.hincrby('rate-limit-violations', event.ip, 1);
 
  // If too many violations, blacklist IP
  const violations = await redis.hget('rate-limit-violations', event.ip);
  if (parseInt(violations || '0') > 100) {
    await redis.sadd('blacklisted-ips', event.ip);
    console.error('Blacklisted IP:', event.ip);
  }
});

Metrics Dashboard

import { Counter, Histogram } from 'prom-client';
 
const rateLimitHits = new Counter({
  name: 'rate_limit_hits_total',
  help: 'Total number of rate limit hits',
  labelNames: ['limiter', 'endpoint'],
});
 
const requestDuration = new Histogram({
  name: 'request_duration_seconds',
  help: 'Request duration in seconds',
  labelNames: ['method', 'route', 'status'],
  buckets: [0.1, 0.5, 1, 2, 5],
});
 
// Track metrics
app.use((req, res, next) => {
  const start = Date.now();
 
  res.on('finish', () => {
    const duration = (Date.now() - start) / 1000;
 
    requestDuration.observe(
      {
        method: req.method,
        route: req.route?.path || req.path,
        status: res.statusCode,
      },
      duration
    );
 
    if (res.statusCode === 429) {
      rateLimitHits.inc({
        limiter: 'unknown',
        endpoint: req.path,
      });
    }
  });
 
  next();
});
 
// Expose metrics
app.get('/metrics', (req, res) => {
  res.set('Content-Type', register.contentType);
  res.end(register.metrics());
});

Testing Rate Limits

import { describe, it, expect, beforeEach } from 'vitest';
import request from 'supertest';
 
describe('Rate Limiting', () => {
  beforeEach(async () => {
    await redis.flushdb();
  });
 
  it('should allow requests within limit', async () => {
    for (let i = 0; i < 5; i++) {
      const res = await request(app)
        .post('/api/auth/login')
        .send({ email: 'test@test.com', password: 'password' });
 
      expect(res.status).not.toBe(429);
    }
  });
 
  it('should block requests exceeding limit', async () => {
    // Make 5 requests (limit)
    for (let i = 0; i < 5; i++) {
      await request(app)
        .post('/api/auth/login')
        .send({ email: 'test@test.com', password: 'wrong' });
    }
 
    // 6th request should be blocked
    const res = await request(app)
      .post('/api/auth/login')
      .send({ email: 'test@test.com', password: 'wrong' });
 
    expect(res.status).toBe(429);
    expect(res.body.error).toContain('Too many');
  });
 
  it('should reset after window expires', async () => {
    // Hit limit
    for (let i = 0; i < 5; i++) {
      await request(app)
        .post('/api/auth/login')
        .send({ email: 'test@test.com', password: 'wrong' });
    }
 
    // Wait for window to expire
    await new Promise((resolve) => setTimeout(resolve, 16 * 60 * 1000));
 
    // Should work again
    const res = await request(app)
      .post('/api/auth/login')
      .send({ email: 'test@test.com', password: 'password' });
 
    expect(res.status).not.toBe(429);
  });
});

Production Results

After implementing these security measures in Aegis2FA:

  • Blocked 10,000+ brute force attempts in first month
  • Zero successful CSRF attacks
  • 99.99% legitimate request success rate
  • Sub-5ms rate limit checking with Redis
  • Reduced malicious traffic by 95%

Checklist for Production

  • Global rate limit (100 req/15min)
  • Auth endpoint rate limit (5 req/15min)
  • CSRF protection on state-changing endpoints
  • Security headers with Helmet.js
  • Redis for distributed rate limiting
  • Monitoring and alerts
  • IP blacklisting for repeat offenders
  • User-specific rate limits
  • Adaptive limits based on risk
  • Rate limit info in response headers
  • Comprehensive tests

Conclusion

Rate limiting and CSRF protection are not optional. They're the difference between a secure production app and a vulnerable target.

Start with simple IP-based limits, then add user-specific and adaptive limits as you scale.


Questions about rate limiting strategies? Need help with Redis configuration? Open an issue on GitHub!