Rate Limiting and Lockout
Auth endpoints are the most attacked routes you have. What to limit, by what key, and how to fail.
Every authentication endpoint is a guessing oracle. Rate limiting is what turns "try a million passwords" into "try twenty".
Limit by more than IP
IP-only limiting fails in both directions: a corporate NAT puts thousands of legitimate users behind one address, while a botnet gives one attacker thousands of addresses. Use several keys at once:
- Per IP — stops a single noisy machine.
- Per account — stops a distributed attack on one high-value login.
- Global per endpoint — a circuit breaker for a broad campaign.
Suggested limits
| Endpoint | Per IP | Per account |
|---|---|---|
POST /auth/login | 20 / 15 min | 10 / 15 min |
POST /auth/register | 5 / hour | — |
POST /auth/forgot | 5 / hour | 3 / hour |
POST /mfa/verify | 10 / 15 min | 5 / 15 min |
POST /auth/refresh | 60 / hour | — |
Reset on success
Count failures, and clear the counter on a successful login. Otherwise a user who mistypes twice and then succeeds still carries a penalty they cannot see.
Exponential backoff over hard lockout
A hard lockout is itself a denial of service: an attacker can lock any account they know the email of. Increasing delays slow an attacker to uselessness without letting them lock out a real user.
Get the client IP right
Behind a proxy, req.ip is the proxy. Configure trusted proxies so the real client address is used — otherwise every request shares one key and your limiter is either useless or blocking everyone.
Answer honestly
Return 429 with a Retry-After header. Silently dropping requests makes clients retry harder and makes your logs useless.
Example
import rateLimit from 'express-rate-limit';
export const loginIpLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 20,
standardHeaders: 'draft-7', // RateLimit-* and Retry-After
legacyHeaders: false,
skipSuccessfulRequests: true, // count failures, not logins
message: { error: 'too_many_attempts' },
});
app.set('trust proxy', 1); // or req.ip is your load balancer, alwaysWhen to use it
- A credential stuffing run from 4,000 IP addresses against one account is stopped by the per-account counter that the per-IP limit could not see.
- A user who mistypes their password twice and then succeeds starts from zero again, because the counter resets on success.
- A limiter that was silently keying every request to the load balancer's IP is fixed by configuring trusted proxies.
More examples
Layered limits with backoff
Escalating to a CAPTCHA rather than a block keeps legitimate users able to sign in during an attack, which a flat global limit would not.
import rateLimit from 'express-rate-limit';
// --- Layer 1: per IP ---
const ipLimiter = rateLimit({
windowMs: 15 * 60e3, limit: 20,
standardHeaders: 'draft-7', legacyHeaders: false,
skipSuccessfulRequests: true,
keyGenerator: (req) => req.ip,
});
// --- Layer 2: per account, with exponential backoff instead of lockout ---
async function accountBackoff(req, res, next) {
const email = String(req.body?.email ?? '').toLowerCase().trim();
if (!email) return next();
const key = `login-fail:${email}`;
const fails = Number(await redis.get(key)) || 0;
if (fails > 0) {
// 1s, 2s, 4s, 8s … capped at 5 minutes. Never a permanent lock.
const waitMs = Math.min(2 ** (fails - 1) * 1000, 300_000);
const lastAt = Number(await redis.get(`${key}:at`)) || 0;
const remaining = lastAt + waitMs - Date.now();
if (remaining > 0) {
res.set('Retry-After', String(Math.ceil(remaining / 1000)));
return res.status(429).json({
error: 'too_many_attempts',
retryAfter: Math.ceil(remaining / 1000),
});
}
}
res.on('finish', async () => {
if (res.statusCode === 401) {
await redis.multi()
.incr(key).expire(key, 3600)
.set(`${key}:at`, Date.now(), 'EX', 3600)
.exec();
} else if (res.statusCode < 400) {
await redis.del(key, `${key}:at`); // clean slate on success
}
});
next();
}
// --- Layer 3: global circuit breaker ---
async function globalGuard(req, res, next) {
const failures = Number(await redis.get('login-fail:global')) || 0;
if (failures > 10_000) {
// A campaign is under way — require a CAPTCHA rather than shutting the door.
if (!(await verifyCaptcha(req.body?.captchaToken))) {
return res.status(429).json({ error: 'captcha_required' });
}
}
next();
}
app.post('/auth/login', ipLimiter, accountBackoff, globalGuard, loginHandler);Getting the client IP right behind proxies
Trusting an unbounded number of hops is worse than trusting none: an attacker prepends whatever X-Forwarded-For they like and gets a fresh bucket per request.
// ❌ Without this, req.ip is the load balancer for EVERY request:
// one key, so either nobody is limited or everybody is.
// app.set('trust proxy', ...) ← not configured
// ✅ Trust exactly as many hops as you actually run.
app.set('trust proxy', 1); // one proxy in front of the app
// app.set('trust proxy', 2); // CDN → load balancer → app
// app.set('trust proxy', ['10.0.0.0/8']); // or by network
// ⚠️ NEVER 'trust proxy', true in a publicly reachable app: the client can then
// forge X-Forwarded-For and rotate its own rate-limit key at will.
// Verify what your app actually sees, in production, once:
app.get('/debug/ip', (req, res) => res.json({
ip: req.ip,
ips: req.ips,
xff: req.get('x-forwarded-for'),
remote: req.socket.remoteAddress,
}));
// curl https://dfg.com/debug/ip
// { "ip": "203.0.113.42", ← should be the real client
// "ips": ["203.0.113.42"],
// "xff": "203.0.113.42, 10.0.1.5",
// "remote": "10.0.1.5" } ← the proxy
//
// If "ip" equals "remote", your limiter is keying on the proxy. Fix it, then
// remove this route.
Discussion