Rate Limiting APIs: The Complete Node.js & Express Implementation Guide
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.
| Attack | Without Rate Limiting | With Rate Limiting |
|---|---|---|
| Credential stuffing | 100K attempts/minute | 5 attempts/minute |
| API scraping | Full database exfiltration | Partial data, slowed |
| DDoS | Service down | Graceful degradation |
| Brute force OTP | Cracked in seconds | Locked after 3 tries |
| Cloud cost attack | $50K surprise bill | Capped 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
- Rate limiting by IP only — Shared IPs (offices, VPNs) block all users. Use API key + IP combo.
- Not rate limiting authenticated endpoints — Stolen tokens can scrape everything.
- Returning different errors for valid/invalid usernames — Enables enumeration even with rate limiting.
- Client-side rate limiting only — Trivially bypassed with curl.
- Forgetting WebSocket endpoints — Rate limit connection attempts and message frequency.
Advertisement
Free Security Tools
Try our tools now
Expert Services
Get professional help
OWASP Top 10
Learn the top risks
Related Articles
Threat Modeling for Developers: STRIDE, PASTA & DREAD with Practical Examples
Threat modeling is the most cost-effective security activity — finding design flaws before writing code. This guide covers STRIDE, PASTA, and DREAD methodologies with real-world examples for web, API, and cloud applications.
Building a Security Champions Program: Scaling Security Across Dev Teams
Security teams can't review every line of code. Security Champions embed security expertise in every development team. This guide covers program design, champion selection, training, metrics, and sustaining engagement.
The Ultimate Secure Code Review Checklist for 2025
A comprehensive, language-agnostic checklist for secure code reviews. Use this as your team's standard for catching vulnerabilities before they reach production.