Rate Limiting APIs: The Complete Node.js & Express Implementation Guide

SCRs Team
March 25, 2026
14 min read
237 words
Share

Why Rate Limiting Is Your First Line of Defense

Every API without rate limiting is a DDoS target, a credential stuffing playground, and a cost explosion waiting to happen.

AttackWithout Rate LimitingWith Rate Limiting
Credential stuffing100K attempts/minute5 attempts/minute
API scrapingFull database exfiltrationPartial data, slowed
DDoSService downGraceful degradation
Brute force OTPCracked in secondsLocked after 3 tries
Cloud cost attack$50K surprise billCapped at normal usage

Strategy 1: Fixed Window Counter

Simplest approach — count requests per time window.

// Simple in-memory fixed window (single server only)
const windowMs = 60 * 1000; // 1 minute
const maxRequests = 100;
const clients = new Map<string, { count: number; resetAt: number }>();

function fixedWindowLimit(clientId: string): boolean {
  const now = Date.now();
  const client = clients.get(clientId);
  
  if (!client || now > client.resetAt) {
    clients.set(clientId, { count: 1, resetAt: now + windowMs });
    return true; // Allowed
  }
  
  if (client.count >= maxRequests) {
    return false; // Rate limited
  }
  
  client.count++;
  return true;
}

Flaw: Burst at window boundary. A client can send 100 requests at 0:59 and 100 more at 1:00 — 200 requests in 2 seconds.


Strategy 2: Sliding Window Log

Tracks exact timestamps of each request. More accurate, more memory.

function slidingWindowLog(clientId: string, window: number, max: number): boolean {
  const now = Date.now();
  const key = \`ratelimit:${clientId}\`;
  
  // Get request timestamps for this client
  let timestamps = requestLogs.get(key) || [];
  
  // Remove expired timestamps
  timestamps = timestamps.filter(t => t > now - window);
  
  if (timestamps.length >= max) {
    return false;
  }
  
  timestamps.push(now);
  requestLogs.set(key, timestamps);
  return true;
}

Strategy 3: Token Bucket (Best for APIs)

Allows bursts while maintaining average rate — ideal for API rate limiting.

class TokenBucket {
  private tokens: number;
  private lastRefill: number;
  
  constructor(
    private maxTokens: number,
    private refillRate: number, // tokens per second
  ) {
    this.tokens = maxTokens;
    this.lastRefill = Date.now();
  }
  
  consume(count: number = 1): boolean {
    this.refill();
    
    if (this.tokens >= count) {
      this.tokens -= count;
      return true;
    }
    return false;
  }
  
  private refill() {
    const now = Date.now();
    const elapsed = (now - this.lastRefill) / 1000;
    this.tokens = Math.min(
      this.maxTokens,
      this.tokens + elapsed * this.refillRate
    );
    this.lastRefill = now;
  }
}

Production Setup: Redis-Backed Sliding Window

For multi-server deployments, use Redis:

import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL);

async function rateLimitRedis(
  identifier: string,
  limit: number,
  windowSeconds: number
): Promise<{ allowed: boolean; remaining: number; resetIn: number }> {
  const key = \`rl:${identifier}\`;
  const now = Date.now();
  const windowStart = now - windowSeconds * 1000;
  
  const pipeline = redis.pipeline();
  
  // Remove old entries
  pipeline.zremrangebyscore(key, 0, windowStart);
  // Add current request
  pipeline.zadd(key, now.toString(), \`${now}-${Math.random()}\`);
  // Count requests in window
  pipeline.zcard(key);
  // Set expiry
  pipeline.expire(key, windowSeconds);
  
  const results = await pipeline.exec();
  const requestCount = results![2][1] as number;
  
  return {
    allowed: requestCount <= limit,
    remaining: Math.max(0, limit - requestCount),
    resetIn: windowSeconds,
  };
}

Express Middleware Implementation

import { Request, Response, NextFunction } from 'express';

interface RateLimitConfig {
  windowMs: number;
  max: number;
  keyGenerator?: (req: Request) => string;
  message?: string;
  skipSuccessfulRequests?: boolean;
}

function createRateLimiter(config: RateLimitConfig) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const key = config.keyGenerator?.(req) 
      ?? req.ip 
      ?? req.headers['x-forwarded-for'] as string
      ?? 'unknown';
    
    const { allowed, remaining, resetIn } = await rateLimitRedis(
      key,
      config.max,
      config.windowMs / 1000
    );
    
    // Always set headers
    res.set({
      'X-RateLimit-Limit': config.max.toString(),
      'X-RateLimit-Remaining': remaining.toString(),
      'X-RateLimit-Reset': Math.ceil(Date.now() / 1000 + resetIn).toString(),
      'Retry-After': resetIn.toString(),
    });
    
    if (!allowed) {
      return res.status(429).json({
        error: config.message || 'Too many requests',
        retryAfter: resetIn,
      });
    }
    
    next();
  };
}

// Usage
app.use('/api/', createRateLimiter({ windowMs: 60000, max: 100 }));
app.use('/api/auth/login', createRateLimiter({ 
  windowMs: 900000, max: 5,
  keyGenerator: (req) => \`login:${req.body?.email || req.ip}\`,
  message: 'Too many login attempts. Try again in 15 minutes.',
}));
app.use('/api/auth/forgot-password', createRateLimiter({ windowMs: 3600000, max: 3 }));

Tiered Rate Limiting by Plan

const PLAN_LIMITS = {
  free:       { rpm: 20,   rpd: 1000 },
  starter:    { rpm: 100,  rpd: 10000 },
  pro:        { rpm: 500,  rpd: 100000 },
  enterprise: { rpm: 5000, rpd: 1000000 },
};

async function tieredRateLimit(req: Request, res: Response, next: NextFunction) {
  const apiKey = req.headers['x-api-key'] as string;
  const plan = await getPlanForKey(apiKey);
  const limits = PLAN_LIMITS[plan || 'free'];
  
  const [minuteResult, dayResult] = await Promise.all([
    rateLimitRedis(\`${apiKey}:min\`, limits.rpm, 60),
    rateLimitRedis(\`${apiKey}:day\`, limits.rpd, 86400),
  ]);
  
  if (!minuteResult.allowed || !dayResult.allowed) {
    return res.status(429).json({ error: 'Rate limit exceeded', plan });
  }
  
  next();
}

Common Mistakes

  1. Rate limiting by IP only — Shared IPs (offices, VPNs) block all users. Use API key + IP combo.
  2. Not rate limiting authenticated endpoints — Stolen tokens can scrape everything.
  3. Returning different errors for valid/invalid usernames — Enables enumeration even with rate limiting.
  4. Client-side rate limiting only — Trivially bypassed with curl.
  5. Forgetting WebSocket endpoints — Rate limit connection attempts and message frequency.
Editorial standards

Published by SecureCodeReviews

This article is part of our original AI security and cybersecurity content library. We show publish and update dates, keep company and policy pages public, and update important guidance when material changes affect readers.

Named author: SCRs Team
Published: Mar 25, 2026
Update status: current publication version

Questions or corrections?

Review our editorial standards, learn more about the company, or contact us if a page needs clarification.

API Security Review

Shipping an API that needs a hard security pass?

We review authentication, authorization, business logic abuse, rate limiting, and the edge cases automated scanners usually miss.

OWASP API risk coverage and business logic testing
Auth and access control review with practical fixes
Clear technical findings your team can act on fast

Talk to SecureCodeReviews

Get a scoped review path fast

Manual review
Actionable fixes
Fast turnaround
Security-focused

Advertisement