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

EndpointPer IPPer account
POST /auth/login20 / 15 min10 / 15 min
POST /auth/register5 / hour
POST /auth/forgot5 / hour3 / hour
POST /mfa/verify10 / 15 min5 / 15 min
POST /auth/refresh60 / 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

Example · javascript
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, always

When 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.

Example · javascript
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.

Example · javascript
// ❌ 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

  • Be the first to comment on this lesson.