Step-by-step
-
1
Install express-rate-limit
express-rate-limitis the standard package for this. It is maintained by the Express ecosystem and covers the common cases with minimal configuration.bashnpm install express-rate-limit -
2
Apply a global limit across all routes
A loose global limit catches runaway clients before they saturate your server. Attach it before your router definitions.
javascriptconst express = require('express'); const rateLimit = require('express-rate-limit'); const app = express(); const globalLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 200, // requests per window per IP standardHeaders: true, // emit RateLimit-* headers (RFC 6585) legacyHeaders: false, // disable X-RateLimit-* (deprecated) message: { error: { code: 'TOO_MANY_REQUESTS', message: 'Slow down.' } }, }); app.use(globalLimiter); -
3
Tighten limits on auth endpoints
Login, password reset, and OTP endpoints are brute-force targets. Apply a much stricter limiter directly to those routes. Five attempts in 15 minutes is a reasonable ceiling for a login endpoint.
javascriptconst authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 5, skipSuccessfulRequests: true, // only count failed attempts message: { error: { code: 'AUTH_RATE_LIMITED', message: 'Too many login attempts. Try again in 15 minutes.', }, }, }); app.post('/auth/login', authLimiter, loginHandler); app.post('/auth/forgot-password', authLimiter, forgotPasswordHandler); -
4
Key by IP and authenticated user ID
The default key is the client IP. For authenticated routes, keying by user ID prevents an attacker from cycling IPs to bypass the limit. Return the user ID when present, otherwise fall back to IP.
javascriptconst userAwareLimiter = rateLimit({ windowMs: 60 * 1000, // 1 minute max: 60, keyGenerator: (req) => { // req.user is set by your auth middleware when the token is valid return req.user ? `user:${req.user.id}` : req.ip; }, }); app.use('/api', authenticateOptional, userAwareLimiter); -
5
Understand the RateLimit response headers
With
standardHeaders: true, every response carries:RateLimit-Limit— the max requests allowedRateLimit-Remaining— requests left in the current windowRateLimit-Reset— Unix timestamp when the window resets
When the limit is hit, the library sends HTTP 429 and a
Retry-Afterheader (seconds until reset). Well-behaved clients respect it; log and block those that don't. -
6
Use a Redis store for multi-instance deployments
The default in-memory store does not share state between processes or machines. If you run more than one Node process (cluster, PM2, Kubernetes), each instance counts independently — the limit becomes effectively multiplied. Switch to a Redis-backed store.
bashnpm install rate-limit-redis ioredis -
7
Configure the Redis store
Pass the store option to your limiter. The counter now lives in Redis and is shared across all instances.
javascriptconst { RedisStore } = require('rate-limit-redis'); const Redis = require('ioredis'); const redisClient = new Redis(process.env.REDIS_URL); const globalLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 200, standardHeaders: true, legacyHeaders: false, store: new RedisStore({ sendCommand: (...args) => redisClient.call(...args), }), }); -
8
Log and monitor rate-limit events
Knowing who is hitting your limits is as important as enforcing them. Override the
handlerto log before sending the 429 response. Feed those logs to your monitoring system (Datadog, Grafana, Logtail) and alert on spikes.javascriptconst logger = require('./logger'); // pino, winston, etc. const globalLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 200, standardHeaders: true, legacyHeaders: false, handler: (req, res, next, options) => { logger.warn({ event: 'rate_limit_exceeded', ip: req.ip, userId: req.user?.id ?? null, path: req.path, method: req.method, }); res.status(options.statusCode).json(options.message); }, });
Tips & gotchas
- Set <code>trustProxy: true</code> (or <code>app.set('trust proxy', 1)</code>) when your app sits behind a reverse proxy like Nginx or a load balancer — otherwise <code>req.ip</code> is always the proxy's address.
- Use <code>skip</code> to whitelist internal health-check routes or your own monitoring IPs so they never trigger the limit.
- The in-memory store is fine for a single-process dev environment, but always use Redis in staging and production.
- Do not set limits so tight that they trigger under normal usage — analyse your real traffic patterns first.
Wrapping up
Rate limiting is table stakes for any public API. Apply a loose global limit, a strict limit on auth routes, key by user ID when authenticated, and back it with Redis the moment you scale beyond one process. The whole setup takes under an hour and protects you against a wide class of abuse and accidental overload.