security2025-03-22ยท9ยท140/348

Express.js rate limiting ๊ตฌํ˜„

Implementing rate limiting in Express.js with in-memory, Redis-backed, and sliding window approaches to protect APIs from abuse.

Express.js rate limiting ๊ตฌํ˜„

Introduction

Rate limiting is essential for protecting APIs from abuse, DDoS attacks, and accidental overload. Without rate limiting, a single client can exhaust your server resources. This post covers multiple rate limiting strategies for Express.js.

Environment

node --version
# v20.11.0

npm list express express-rate-limit
# my-app@1.0.0
# +-- express@4.18.2
# +-- express-rate-limit@7.1.5

Problem

Without rate limiting, the API is vulnerable:

// No rate limiting - vulnerable to abuse
app.post('/api/login', async (req, res) => {
  const { email, password } = req.body;
  
  // Attacker can try thousands of passwords per second!
  const user = await authenticate(email, password);
  
  if (user) {
    res.json({ token: generateToken(user) });
  } else {
    res.status(401).json({ error: 'Invalid credentials' });
  }
});

Analysis

Rate limiting strategies:

Fixed Window: Count requests per time window
Sliding Window: Count requests in rolling window
Token Bucket: Allow bursts with sustained rate
Leaky Bucket: Process requests at fixed rate

The rate limiting flow:

Request โ†’ Check count โ†’ Under limit? โ†’ Allow โ†’ Increment count
                     โ†’ Over limit? โ†’ Reject (429)

Solution

Solution 1: Basic rate limiting with express-rate-limit

const rateLimit = require('express-rate-limit');

// General API rate limit
const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,                  // 100 requests per window
  message: {
    error: 'Too many requests',
    retryAfter: '15 minutes'
  },
  standardHeaders: true,
  legacyHeaders: false
});

app.use('/api/', apiLimiter);

Solution 2: Different limits for different endpoints

const rateLimit = require('express-rate-limit');

// Strict limit for authentication endpoints
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,                    // 5 attempts per 15 minutes
  message: {
    error: 'Too many login attempts',
    retryAfter: '15 minutes'
  }
});

// General API limit
const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100
});

// Lenient limit for public endpoints
const publicLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 200
});

app.use('/api/auth/', authLimiter);
app.use('/api/', apiLimiter);
app.use('/api/public/', publicLimiter);

Solution 3: Redis-backed rate limiting (distributed)

const RedisStore = require('rate-limit-redis');
const { createClient } = require('redis');

const redisClient = createClient({
  url: 'redis://localhost:6379'
});

redisClient.connect();

const redisLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
  store: new RedisStore({
    sendCommand: (...args) => redisClient.sendCommand(args),
    prefix: 'rl:'  // Key prefix
  })
});

app.use('/api/', redisLimiter);

Solution 4: Sliding window rate limiting

class SlidingWindowRateLimiter {
  constructor(redisClient, options = {}) {
    this.redis = redisClient;
    this.windowMs = options.windowMs || 60000;
    this.maxRequests = options.maxRequests || 100;
  }
  
  async isAllowed(key) {
    const now = Date.now();
    const windowStart = now - this.windowMs;
    
    // Remove old entries
    await this.redis.zremrangebyscore(key, 0, windowStart);
    
    // Count current requests
    const count = await this.redis.zcard(key);
    
    if (count >= this.maxRequests) {
      return { allowed: false, retryAfter: this.windowMs };
    }
    
    // Add current request
    await this.redis.zadd(key, now, `${now}-${Math.random()}`);
    await this.redis.expire(key, Math.ceil(this.windowMs / 1000));
    
    return { 
      allowed: true, 
      remaining: this.maxRequests - count - 1 
    };
  }
}

// Usage
const limiter = new SlidingWindowRateLimiter(redisClient, {
  windowMs: 60000,      // 1 minute
  maxRequests: 100       // 100 requests per minute
});

app.use(async (req, res, next) => {
  const key = `rate:${req.ip}`;
  const result = await limiter.isAllowed(key);
  
  if (!result.allowed) {
    return res.status(429).json({
      error: 'Rate limit exceeded',
      retryAfter: Math.ceil(result.retryAfter / 1000)
    });
  }
  
  res.setHeader('X-RateLimit-Remaining', result.remaining);
  next();
});

Solution 5: Custom rate limiter with token bucket

class TokenBucketLimiter {
  constructor(options = {}) {
    this.buckets = new Map();
    this.capacity = options.capacity || 10;
    this.refillRate = options.refillRate || 1; // tokens per second
  }
  
  getToken(key) {
    const now = Date.now();
    
    if (!this.buckets.has(key)) {
      this.buckets.set(key, {
        tokens: this.capacity,
        lastRefill: now
      });
    }
    
    const bucket = this.buckets.get(key);
    
    // Refill tokens
    const elapsed = (now - bucket.lastRefill) / 1000;
    bucket.tokens = Math.min(this.capacity, bucket.tokens + elapsed * this.refillRate);
    bucket.lastRefill = now;
    
    if (bucket.tokens >= 1) {
      bucket.tokens -= 1;
      return true;
    }
    
    return false;
  }
}

const bucketLimiter = new TokenBucketLimiter({
  capacity: 10,
  refillRate: 1
});

app.use((req, res, next) => {
  if (bucketLimiter.getToken(req.ip)) {
    next();
  } else {
    res.status(429).json({ error: 'Rate limit exceeded' });
  }
});

Lessons Learned

  1. Use Redis for distributed rate limiting - In-memory does not work across instances
  2. Set different limits for different endpoints - Auth endpoints need stricter limits
  3. Include rate limit headers - Help clients understand their limits
  4. Consider sliding window - More accurate than fixed window
  5. Test rate limiting - Use tools like ab or wrk to verify

This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.