Rate Limiting and DoS Protection¶
Your app without rate limiting is a public API to anyone with a loop Script kiddies will brute force login endpoints , scrapers will drain your database connections , and if you're not rate limiting you're essentially inviting abuse Rate limiting is not optional security - it's survival
express-rate-limit Basics¶
const rateLimit = require('express-rate-limit')
// Basic limiter - 100 requests per 15 minutes
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP
standardHeaders: true, // Return rate limit info in headers
legacyHeaders: false, // Disable X-RateLimit-* headers
message: {
error: 'too many requests',
retryAfter: '15 minutes'
}
})
// Apply to all routes
app.use(limiter)
// Per-endpoint limiter - stricter for auth
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
message: { error: 'too many login attempts' },
skipSuccessfulRequests: true, // only count failures
standardHeaders: true,
legacyHeaders: false
})
app.post('/api/login', loginLimiter, async (req, res) => {
// login logic
})
Always apply rate limiting globally first : then tighten specific endpoints (login , signup , password reset) with stricter limits
Rate Limiting Headers¶
// express-rate-limit with standard headers enabled
const limiter = rateLimit({
windowMs: 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false
})
// Response headers:
// RateLimit-Limit: 10
// RateLimit-Remaining: 3
// RateLimit-Reset: 1620000000
// Client side: respect the Retry-After header on 429
fetch('/api/endpoint')
.then(res => {
if (res.status === 429) {
const retryAfter = res.headers.get('Retry-After')
console.log(`Back off for ${retryAfter} seconds`)
}
})
Rate limiting headers let clients self-regulate : they don't prevent abuse but they reduce unnecessary requests from well-behaved clients
Rate Limiting Strategies¶
// Token Bucket - fixed capacity , refills over time
class TokenBucket {
constructor(capacity, refillRate, refillInterval) {
this.capacity = capacity
this.tokens = capacity
this.refillRate = refillRate
this.refillInterval = refillInterval
this.lastRefill = Date.now()
}
consume(count = 1) {
this._refill()
if (this.tokens >= count) {
this.tokens -= count
return true
}
return false
}
_refill() {
const now = Date.now()
const elapsed = now - this.lastRefill
const refillTokens = Math.floor(
(elapsed / this.refillInterval) * this.refillRate
)
this.tokens = Math.min(this.capacity, this.tokens + refillTokens)
this.lastRefill = now
}
}
// Usage
const bucket = new TokenBucket(100, 10, 1000) // 100 cap , 10/sec refill
if (bucket.consume()) {
// process request
} else {
// rate limited
}
// Sliding Window - track requests in a moving time window
class SlidingWindow {
constructor(windowMs, maxRequests) {
this.windowMs = windowMs
this.maxRequests = maxRequests
this.requests = new Map() // ip -> [timestamps]
}
isAllowed(ip) {
const now = Date.now()
const cutoff = now - this.windowMs
if (!this.requests.has(ip)) {
this.requests.set(ip, [])
}
let timestamps = this.requests.get(ip)
// Remove old entries
timestamps = timestamps.filter(t => t > cutoff)
this.requests.set(ip, timestamps)
if (timestamps.length >= this.maxRequests) {
return false
}
timestamps.push(now)
return true
}
}
Sliding window is more accurate than fixed window : fixed window can burst at the boundary (60 requests at 59:59 then 60 more at 60:01)
Distributed Rate Limiting with Redis¶
When you have multiple Node processes behind a load balancer , memory-based rate limiting is useless - each process has its own counter An attacker can bypass the limit by hitting different processes
const rateLimit = require('express-rate-limit')
const RedisStore = require('rate-limit-redis')
const { createClient } = require('redis')
const redisClient = createClient({
url: process.env.REDIS_URL,
enableOfflineQueue: false
})
// Handle Redis connection errors gracefully
redisClient.on('error', (err) => {
console.error('Redis rate limit error:', err.message)
})
const limiter = rateLimit({
windowMs: 60 * 1000,
max: 100,
store: new RedisStore({
sendCommand: (...args) => redisClient.sendCommand(args),
prefix: 'ratelimit:'
}),
standardHeaders: true,
legacyHeaders: false
})
app.use(limiter)
// Custom Lua script for atomic rate limiting in Redis
const rateLimitScript = `
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)
if count < limit then
redis.call('ZADD', key, now, now .. ':' .. math.random())
redis.call('EXPIRE', key, math.ceil(window / 1000))
return 1
end
return 0
`
async function checkRateLimit(ip) {
const allowed = await redisClient.eval(rateLimitScript, {
keys: [`ratelimit:${ip}`],
arguments: ['100', '60000', String(Date.now())]
})
return allowed === 1
}
Redis-based rate limiting is essential for production : without it , a distributed brute force with 10 rotating IPs bypasses your limit by 10x
Securing Against Rate Limit Bypasses¶
// DON'T trust X-Forwarded-For blindly - it's user-controlled
router.use(rateLimit({
keyGenerator: (req) => {
// Use a combination of factors
const forwarded = req.headers['x-forwarded-for']
const ip = forwarded
? forwarded.split(',')[0].trim()
: req.ip
// Add user agent to prevent IP rotation bypass
const ua = req.headers['user-agent'] || 'unknown'
return require('crypto')
.createHash('md5')
.update(ip + ua)
.digest('hex')
},
max: 100
}))
Rate limit by user ID for authenticated endpoints : IP-based limits can be bypassed with botnets , but user ID-based limits hold even across IP changes
Custom Rate Limit Middleware¶
function createRateLimiter(name, { windowMs, max, keyFn }) {
const store = new Map()
// Periodic cleanup
setInterval(() => {
const cutoff = Date.now() - windowMs
for (const [key, timestamps] of store) {
const valid = timestamps.filter(t => t > cutoff)
if (valid.length === 0) {
store.delete(key)
} else {
store.set(key, valid)
}
}
}, windowMs)
return (req, res, next) => {
const key = keyFn(req)
const now = Date.now()
const cutoff = now - windowMs
if (!store.has(key)) {
store.set(key, [])
}
const timestamps = store.get(key).filter(t => t > cutoff)
store.set(key, timestamps)
if (timestamps.length >= max) {
res.set('Retry-After', Math.ceil(windowMs / 1000))
return res.status(429).json({
error: 'rate limit exceeded',
name,
retryAfter: `${Math.ceil(windowMs / 1000)} seconds`
})
}
timestamps.push(now)
next()
}
}
app.use('/api/search', createRateLimiter('search', {
windowMs: 60000,
max: 30,
keyFn: (req) => req.ip
}))
Prerequisites¶
- sec_05_helmet.md - security headers before DoS protection
next -> sec_07_dependency_audit.md