Antoine Duno
Founder of ZeriFlow · 10 years fullstack engineering · About the author
Key Takeaways
- An API without rate limiting is an open invitation for abuse — credential stuffing, scraping, denial of service, and exhausting your database connection pool. This guide covers every practical aspect of implementing rate limiting in Node.js, from a five-line express-rate-limit setup to production-grade Redis-backed distributed limiting.
- Includes copy-paste code examples and step-by-step instructions.
- Free automated scan available to verify your implementation.
How to Implement Rate Limiting in Node.js (API Protection Guide)
Every publicly accessible API endpoint is being probed. Bots scan for exposed authentication endpoints within minutes of a domain being registered. Without rate limiting, your API is a free resource for anyone who wants to attempt thousands of password guesses, scrape your entire product catalog, or spam your contact form.
Rate limiting is not optional — it is a baseline security control. This guide covers the implementation options from a quick solution to a production-grade distributed setup.
Why Rate Limiting Matters
The practical consequences of missing rate limiting:
Credential stuffing. Attackers take breach databases (hundreds of millions of username/password pairs) and try them against your login endpoint. Without rate limiting, they can attempt thousands of combinations per minute against a single target.
API key brute-forcing. If your API key format is guessable (sequential IDs, short random strings), attackers can enumerate valid keys by trying them at scale.
Resource exhaustion. A misconfigured crawler, a misbehaving integration partner, or a deliberately malicious actor can overwhelm your server by making thousands of requests per second. This affects all users, not just the attacker.
Data scraping. Without limits on read endpoints, competitors or bad actors can download your entire user base, product catalog, or content library.
Cost amplification attacks. If any of your API routes trigger expensive external services (AI model calls, email sends, SMS messages), unlimited requests mean unlimited costs.
Security scanners including ZeriFlow check for missing or insufficiently strict rate limiting on common endpoint patterns — particularly /auth/login, /auth/register, /api/v*/reset-password, and /api/v*/contact.
Option 1: express-rate-limit (In-Memory)
express-rate-limit is the standard rate limiting middleware for Express applications. For a single-instance deployment or low-traffic applications, the in-memory store is sufficient.
npm install express-rate-limitBasic setup — applying a global limit:
// middleware/rateLimiter.js
const rateLimit = require(''express-rate-limit'');
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // max 100 requests per window per IP
standardHeaders: true, // send RateLimit-* headers (RFC 6585)
legacyHeaders: false, // disable X-RateLimit-* headers
message: {
status: 429,
error: ''Too many requests'',
message: ''Rate limit exceeded. Please try again in 15 minutes.'',
retryAfter: 15 * 60
},
handler: (req, res, next, options) => {
res.status(options.statusCode).json(options.message);
}
});
module.exports = { globalLimiter };// app.js
const express = require(''express'');
const { globalLimiter } = require(''./middleware/rateLimiter'');
const app = express();
// Apply globally before all routes
app.use(globalLimiter);
app.use(''/api'', require(''./routes/api''));Per-Route Limits: Where the Real Work Happens
A single global limit is a blunt instrument. Authentication endpoints need much stricter limits than a public product listing endpoint. Define separate limiters for different route categories:
// middleware/rateLimiter.js
const rateLimit = require(''express-rate-limit'');
// Authentication endpoints — very strict
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // 10 attempts per 15 minutes
skipSuccessfulRequests: true, // don''t count successful logins against the limit
message: {
status: 429,
error: ''Too many login attempts'',
message: ''Account temporarily locked. Please try again in 15 minutes.'',
},
keyGenerator: (req) => {
// Rate limit by both IP and username to prevent IP rotation attacks
return `${req.ip}:${req.body?.email || ''unknown''}`;
}
});
// Password reset — extremely strict (avoid oracle attacks)
const passwordResetLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 5,
message: {
status: 429,
error: ''Too many password reset attempts'',
message: ''Please wait 1 hour before requesting another password reset.''
}
});
// Public API — moderate limit
const apiLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 60, // 60 requests per minute (1 per second average)
standardHeaders: true,
legacyHeaders: false,
});
// Search/listing endpoints — higher limit for read operations
const readLimiter = rateLimit({
windowMs: 60 * 1000,
max: 120,
standardHeaders: true,
legacyHeaders: false,
});
// Contact/email sending endpoints — very strict
const contactLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 5,
message: {
status: 429,
error: ''Contact limit reached'',
message: ''You can only submit 5 contact requests per hour.''
}
});
module.exports = {
authLimiter,
passwordResetLimiter,
apiLimiter,
readLimiter,
contactLimiter
};Apply them to the appropriate routes:
// routes/auth.js
const express = require(''express'');
const router = express.Router();
const { authLimiter, passwordResetLimiter } = require(''../middleware/rateLimiter'');
router.post(''/login'', authLimiter, loginController);
router.post(''/register'', authLimiter, registerController);
router.post(''/forgot-password'', passwordResetLimiter, forgotPasswordController);
router.post(''/reset-password'', passwordResetLimiter, resetPasswordController);
module.exports = router;
// routes/api.js
const { apiLimiter, readLimiter, contactLimiter } = require(''../middleware/rateLimiter'');
router.get(''/products'', readLimiter, getProducts);
router.get(''/search'', readLimiter, searchController);
router.post(''/contact'', contactLimiter, contactController);
router.use(apiLimiter); // default for all other API routesOption 2: Redis-Based Distributed Rate Limiting
In-memory rate limiting breaks when you run more than one server instance. Each instance maintains its own counter, so a user can make 100 requests per instance. With 4 instances behind a load balancer, they can make 400 requests in your "100 per window" limit.
For any production deployment running multiple instances (containers, EC2 instances, serverless with concurrency), you need a shared store. Redis is the standard solution.
npm install express-rate-limit rate-limit-redis ioredis// middleware/rateLimiter.redis.js
const rateLimit = require(''express-rate-limit'');
const RedisStore = require(''rate-limit-redis'');
const Redis = require(''ioredis'');
// Redis connection — use environment variables in production
const redis = new Redis({
host: process.env.REDIS_HOST || ''localhost'',
port: parseInt(process.env.REDIS_PORT || ''6379''),
password: process.env.REDIS_PASSWORD,
tls: process.env.REDIS_TLS === ''true'' ? {} : undefined,
retryStrategy: (times) => {
const delay = Math.min(times * 50, 2000);
return delay;
}
});
redis.on(''error'', (err) => {
console.error(''Redis rate limiter error:'', err.message);
// Don''t crash the app on Redis connection errors
// express-rate-limit will fall back gracefully
});
const createRedisLimiter = (options) => rateLimit({
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (...args) => redis.call(...args),
prefix: ''rl:'', // Redis key prefix
}),
skip: (req) => {
// Skip rate limiting for internal health checks
return req.path === ''/health'' || req.ip === ''127.0.0.1'';
},
...options
});
const authLimiter = createRedisLimiter({
windowMs: 15 * 60 * 1000,
max: 10,
keyGenerator: (req) => `auth:${req.ip}:${req.body?.email || ''''}`,
});
const apiLimiter = createRedisLimiter({
windowMs: 60 * 1000,
max: 60,
});
module.exports = { authLimiter, apiLimiter };The Redis key structure for a 15-minute window looks like:
rl:auth:192.168.1.1:user@example.com → 7 (TTL: 847 seconds)All instances share the same counter. A user who makes 10 requests against instance A has used up their limit — instance B knows about it because both read from Redis.
Sliding Window vs. Fixed Window
The default express-rate-limit behavior is a fixed window: the counter resets at the start of each time window. This creates a "boundary burst" problem: a user can make 100 requests at 11:59 PM and another 100 requests at 12:00 AM — 200 requests in a 2-minute window.
A sliding window calculates the request count over the last N milliseconds from the current moment, eliminating the boundary burst. The rate-limit-redis store supports sliding window natively:
const RedisStore = require(''rate-limit-redis'');
const slidingWindowLimiter = rateLimit({
windowMs: 60 * 1000,
max: 60,
store: new RedisStore({
sendCommand: (...args) => redis.call(...args),
prefix: ''rl:sliding:'',
// rate-limit-redis uses a sliding window by default with sorted sets
}),
standardHeaders: true,
legacyHeaders: false,
});For authentication endpoints, always prefer sliding window to prevent boundary burst exploitation.
Rate Limit Response Headers
Your rate-limited responses should always include standard headers so clients can implement proper backoff:
HTTP/1.1 429 Too Many Requests
RateLimit-Limit: 10
RateLimit-Remaining: 0
RateLimit-Reset: 1746180000
Retry-After: 847
Content-Type: application/json
{
"status": 429,
"error": "rate_limit_exceeded",
"message": "Too many requests. Retry after 847 seconds.",
"retryAfter": 847
}When standardHeaders: true is set in express-rate-limit, the RateLimit-* headers are automatically added. Always include Retry-After to help clients back off gracefully rather than retrying immediately (which amplifies the problem).
Expose remaining limit on successful requests too, not just on 429s. This lets well-behaved clients slow down proactively:
// Middleware to add rate limit context to all successful responses
app.use((req, res, next) => {
const originalJson = res.json.bind(res);
res.json = (body) => {
// express-rate-limit sets these headers automatically
// This ensures they''re visible in the response body too (optional)
return originalJson(body);
};
next();
});Key Generator Strategies
The keyGenerator function determines what constitutes a "user" for rate limiting purposes. The default is the client IP address. This is usually correct but has failure modes:
// Default: IP-based
keyGenerator: (req) => req.ip
// For APIs with authentication: rate limit by API key or user ID (more accurate)
keyGenerator: (req) => {
if (req.user?.id) return `user:${req.user.id}`;
if (req.headers[''x-api-key'']) return `apikey:${req.headers[''x-api-key'']}`;
return `ip:${req.ip}`;
}
// For login: rate limit by both IP and username (prevents IP rotation)
keyGenerator: (req) => {
const identifier = req.body?.email?.toLowerCase() || req.body?.username?.toLowerCase() || ''anonymous'';
return `${req.ip}:${identifier}`;
}
// Behind a proxy/load balancer: trust the X-Forwarded-For header
// IMPORTANT: Only do this if you control the proxy
app.set(''trust proxy'', 1); // trust first hop
// req.ip will now return the original client IPIf your app runs behind a reverse proxy (Nginx, Cloudflare, ALB), ensure app.set(''trust proxy'', 1) is set so req.ip returns the client''s real IP address rather than the proxy''s IP (which would make all clients share one rate limit).
How ZeriFlow Detects Missing Rate Limits
ZeriFlow''s security scan includes checks for missing or insufficiently strict rate limiting. The scanner sends multiple rapid requests to common endpoint patterns and evaluates the response:
POST /login,/auth/login,/api/auth/login,/api/v1/auth/loginPOST /register,/api/register,/api/v1/registerPOST /forgot-password,/reset-passwordPOST /contact,/api/contact
If these endpoints respond with 200 OK to hundreds of rapid requests without returning 429 Too Many Requests, ZeriFlow flags the endpoint as missing rate limiting. The finding appears as a high or critical issue depending on the sensitivity of the endpoint.
To verify your rate limiting is working correctly:
# Test that your login endpoint rate limits properly
for i in {1..15}; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \\
-X POST https://your-app.com/api/auth/login \\
-H "Content-Type: application/json" \\
-d ''{"email": "test@example.com", "password": "wrong-password"}'')
echo "Request $i: HTTP $STATUS"
done
# After request 10, you should see HTTP 429Conclusion
Rate limiting is a security control, an infrastructure protection measure, and a cost management tool simultaneously. The express-rate-limit library makes the initial implementation straightforward — five minutes to a working global limit. The Redis-backed approach takes another hour to implement correctly and handles any scale you will realistically encounter.
Apply strict limits to authentication endpoints first (this is the highest-value security improvement), then work outward to contact forms, user-facing APIs, and finally read-only public endpoints.
Run your API through ZeriFlow to check whether your current rate limiting is sufficient — it checks the same endpoints an attacker would probe first.