A rate limiter is the only defense here that costs an attacker money. express-rate-limit 3,306 (MIT, express-rate-limit/express-rate-limit (https://github.com/express-rate-limit/express-rate-limit 3,306 ), 8.7.0) counts requests per key per window and answers 429 past the limit. Give each class of route its own budget and key.
import { rateLimit, ipKeyGenerator } from 'express-rate-limit';
const readLimiter = rateLimit({
windowMs: 60_000, limit: 5, standardHeaders: 'draft-8', legacyHeaders: false,
message: { error: 'too_many_requests', retryAfterSeconds: 60 }
});
const loginLimiter = rateLimit({
windowMs: 15 * 60_000, limit: 3, standardHeaders: 'draft-8', legacyHeaders: false,
keyGenerator: (req) => (req.body?.email || '') + '|' + ipKeyGenerator(req.ip),
skipSuccessfulRequests: true // only failed attempts count toward the limit
});
app.get('/api/books', readLimiter, (req, res) => res.json({ items: ['Dune'] }));
app.post('/login', loginLimiter, (req, res) =>
res.status(401).json({ error: 'bad credentials' }));request 1 -> 200 ratelimit: "5-in-1min"; r=4; t=60
request 4 -> 200 ratelimit: "5-in-1min"; r=1; t=60
request 5 -> 200 ratelimit: "5-in-1min"; r=0; t=60
request 6 -> 429 ratelimit: "5-in-1min"; r=0; t=60 retry-after: 60
ratelimit-policy: "5-in-1min"; q=5; w=60; pk=:YmIwY2Q1MTM2YWU1:
body: {"error":"too_many_requests","retryAfterSeconds":60}r is remaining, t is seconds until the window resets, and q, w, pk in RateLimit-Policy are quota, window and a hash of the partition key. Those are the IETF draft-8 headers; 'draft-6' emits the older RateLimit-Limit/-Remaining/-Reset triple and legacyHeaders: true the X-RateLimit-* set.
Two settings decide whether the limiter works at all. The key must identify the caller: behind a proxy or CDN, req.ip is the proxy's address unless trust proxy is set correctly (Headers and Proxies), and getting that wrong either throttles all your users as one or lets a client fake its address with X-Forwarded-For. The store must be shared: the memory store counts per process, so four pm2 29,762 workers grant four times the limit and a restart resets every counter. In production use rate-limit-redis 6.0.1 with an ioredis 15,343 client (Server-Side Caching with Redis).
skipSuccessfulRequests is what makes the per-account limit usable: a user who signs in successfully never burns budget, while an attacker spraying passwords at ada@example.com is locked out after three failures from any IP. Return 429 with Retry-After rather than 403: a well-behaved client backs off and retries, while on 403 it gives up and a human opens a support ticket.