Rate limiting and API security are the defences that stand between your Express API and the internet. Rate limiting prevents abuse — brute-force attacks, credential stuffing, scraping, and DoS — while still serving legitimate users. Input sanitisation and validation prevent injection attacks at the application layer. Security headers from Helmet prevent a class of client-side attacks. This lesson builds a complete, layered API security implementation with Redis-backed sliding window rate limiting (more accurate than fixed windows), request fingerprinting, and automated abuse detection.
Rate Limiting Algorithms
| Algorithm | Accuracy | Memory | Best For |
|---|---|---|---|
| Fixed window | Allows burst at window boundary (2× rate at worst) | O(1) per key | Simple counting — login attempts per hour |
| Sliding window log | Exact — records every request timestamp | O(requests in window) | Exact rate limiting, low traffic APIs |
| Sliding window counter | Approximate — weighted average of two windows | O(1) per key | High-traffic APIs — Redis-efficient approach |
| Token bucket | Allows controlled bursts up to bucket size | O(1) per key | APIs that legitimately need short bursts |
| Leaky bucket | Smoothed output rate | O(1) per key | Downstream services with strict rate limits |
Note: Always rate limit on multiple dimensions simultaneously: global rate limit (100 req/min per IP), route-specific limits (10 req/min on auth endpoints), and user-specific limits after authentication (1000 req/hour per user). These three tiers prevent different attack vectors: global limits stop unauthenticated abuse, route-specific limits protect high-value endpoints, and user limits prevent authenticated API abuse. Store rate limit state in Redis — not in-process memory — so limits are enforced across all server instances.
Tip: Use Redis sorted sets for a true sliding window:
ZADD requests timestamp timestamp (score and member both set to timestamp), then ZREMRANGEBYSCORE requests 0 (now-window) to remove old entries, then ZCARD requests to count. This gives exact count over a rolling window. Wrap in a Lua script to make it atomic — a Redis Lua script executes atomically, preventing race conditions between the ZADD, ZREMRANGEBYSCORE, and ZCARD operations.Warning: Rate limiting by IP address is the minimum baseline but not sufficient alone. Shared NAT (corporate networks, mobile carriers) can put thousands of users behind one IP — a per-IP limit of 100 requests/minute would block the entire company. Use IP as the fallback for unauthenticated requests, but switch to user ID for authenticated requests. Also consider rate limiting by API key, account tier, or a combination of IP + User-Agent fingerprint for better accuracy.
Complete Rate Limiting and Security
// ── Redis sliding window rate limiter ─────────────────────────────────────
// src/middleware/rate-limiter.js
const { getRedisClient } = require('../config/redis');
// Lua script — atomic sliding window check
const slidingWindowScript = `
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local expiry = tonumber(ARGV[4])
-- Remove timestamps older than the window
redis.call('ZREMRANGEBYSCORE', key, '-inf', now - window)
-- Count requests in the current window
local count = redis.call('ZCARD', key)
if count >= limit then
return { 0, count, redis.call('ZRANGE', key, 0, 0)[1] }
end
-- Add current request (score=now, member=unique id using now+count)
redis.call('ZADD', key, now, now .. '-' .. count)
redis.call('EXPIRE', key, expiry)
return { 1, count + 1, 0 }
`;
async function slidingWindowRateLimit(identifier, windowMs, limit) {
const redis = await getRedisClient();
const now = Date.now();
const key = `ratelimit:${identifier}`;
const expiry = Math.ceil(windowMs / 1000) + 1;
const [allowed, count, oldestMs] = await redis.eval(
slidingWindowScript,
{ keys: [key], arguments: [String(now), String(windowMs), String(limit), String(expiry)] }
);
const resetAt = oldestMs ? Number(oldestMs) + windowMs : now + windowMs;
const retryAfter= Math.ceil((resetAt - now) / 1000);
return {
allowed: Boolean(allowed),
count: Number(count),
limit,
retryAfter: allowed ? 0 : retryAfter,
resetAt: new Date(resetAt).toISOString(),
};
}
// ── Rate limit middleware factory ──────────────────────────────────────────
function rateLimitMiddleware({ windowMs, limit, keyFn, message }) {
return async (req, res, next) => {
try {
const identifier = keyFn(req);
const result = await slidingWindowRateLimit(identifier, windowMs, limit);
// Always set informational headers
res.setHeader('X-RateLimit-Limit', result.limit);
res.setHeader('X-RateLimit-Remaining', Math.max(0, result.limit - result.count));
res.setHeader('X-RateLimit-Reset', result.resetAt);
if (!result.allowed) {
res.setHeader('Retry-After', result.retryAfter);
return res.status(429).json({
message,
retryAfter: result.retryAfter,
resetAt: result.resetAt,
});
}
next();
} catch (err) {
// Redis unavailable — fail open (allow) to prevent Redis outage = API outage
console.error('Rate limit check failed:', err.message);
next();
}
};
}
// Pre-configured rate limiters
const globalLimiter = rateLimitMiddleware({
windowMs: 60_000,
limit: 100,
keyFn: req => `ip:${req.ip}`,
message: 'Too many requests. Please slow down.',
});
const authLimiter = rateLimitMiddleware({
windowMs: 15 * 60_000,
limit: 10,
keyFn: req => `auth:${req.ip}`,
message: 'Too many login attempts. Try again in 15 minutes.',
});
const userLimiter = rateLimitMiddleware({
windowMs: 60_000,
limit: 300,
keyFn: req => `user:${req.user?.sub || req.ip}`,
message: 'API rate limit exceeded.',
});
module.exports = { globalLimiter, authLimiter, userLimiter };
// ── Abuse detection — track suspicious patterns ───────────────────────────
// src/middleware/abuse-detection.js
const SUSPICIOUS_PATTERNS = [
/\b(union|select|insert|update|delete|drop|exec|execute)\b/i, // SQL injection
/