Secure Node.js Rate Limiting with Sliding Window

Rate limiting is a critical security measure used to protect APIs from abuse, brute-force attacks, and Denial of Service (DoS) attempts. This article provides a practical guide on how to implement a secure rate limiter in Node.js using the sliding window log algorithm. We will explore the mechanics of this algorithm and walk through a production-ready implementation using Redis to ensure atomic operations and distributed accuracy.

Understanding the Sliding Window Log Algorithm

Unlike the fixed window algorithm, which resets at specific time intervals and can allow a burst of traffic at the window boundaries, the sliding window log algorithm tracks the exact timestamp of every request.

When a user makes a request, the algorithm: 1. Deletes all logged timestamps that are older than the defined sliding window limit. 2. Adds the current request’s timestamp to the log. 3. Calculates the total number of remaining logs. 4. Approves the request if the count is below the limit, or rejects it if it exceeds the threshold.

To implement this securely and efficiently in Node.js, we use Redis and its Sorted Sets (ZSET). This prevents memory exhaustion on the application server and ensures accurate tracking across multiple server instances.

Step-by-Step Implementation in Node.js

1. Prerequisites

First, install the necessary dependencies. You will need express for the web server and ioredis for connecting to Redis.

npm install express ioredis

2. The Rate Limiter Logic

The sliding window log is managed atomically using a Redis transaction (multi). We use the client’s IP address (or API key) as the key, the timestamp as both the score and the member in the Sorted Set.

Create a file named rateLimiter.js:

const Redis = require('ioredis');
const redis = new Redis(); // Connects to localhost:6379 by default

/**
 * Checks if a request exceeds the allowed rate limit.
 * @param {string} key - Unique identifier (e.g., IP address or API key).
 * @param {number} limit - Maximum number of allowed requests.
 * @param {number} windowInSeconds - Time window size in seconds.
 * @returns {Promise<boolean>} - True if rate limited, false otherwise.
 */
async function isRateLimited(key, limit, windowInSeconds) {
  const now = Date.now();
  const windowStart = now - (windowInSeconds * 1000);

  const transaction = redis.multi();

  // 1. Remove elements (timestamps) older than the current window start
  transaction.zremrangebyscore(key, 0, windowStart);

  // 2. Add the current timestamp to the sorted set
  transaction.zadd(key, now, now);

  // 3. Count the remaining elements in the set
  transaction.zcard(key);

  // 4. Set an expiration on the key to automatically clean up inactive users
  transaction.expire(key, windowInSeconds);

  const results = await transaction.exec();

  // The result of zcard is the third command in the multi transaction (index 2)
  const requestCount = results[2][1];

  return requestCount > limit;
}

module.exports = isRateLimited;

3. Integrating with Express Middleware

Now, let’s create an Express middleware to intercept incoming requests and apply the rate limiting logic.

Create a file named server.js:

const express = require('express');
const isRateLimited = require('./rateLimiter');

const app = express();

// Enable trust proxy if your app is behind a reverse proxy (like Nginx or Cloudflare)
app.set('trust proxy', true);

const rateLimitMiddleware = async (req, res, next) => {
  const clientIp = req.ip;
  const LIMIT = 100; // Max 100 requests
  const WINDOW = 60; // Per 60 seconds

  try {
    const limited = await isRateLimited(`rate_limit:${clientIp}`, LIMIT, WINDOW);

    if (limited) {
      return res.status(429).json({
        error: 'Too Many Requests',
        message: 'You have exceeded your request limit. Please try again later.'
      });
    }

    next();
  } catch (error) {
    console.error('Rate limiter error:', error);
    // Fail-open or fail-closed based on your security policy
    next();
  }
};

app.get('/api/resource', rateLimitMiddleware, (req, res) => {
  res.json({ message: 'Success! You accessed the protected resource.' });
});

app.listen(3000, () => {
  console.log('Server running on port 3000');
});

Security and Performance Considerations

While highly accurate, the sliding window log algorithm has specific trade-offs: