Our services

If you can think it, we can make it brainsoft.

Rate limiting authentication endpoints against credential stuffing

Written By: BrainSoft In Security

Credential stuffing is boring to defend against and boring to watch: someone takes a list of leaked email and password pairs, points a script at your login form, and tries them all. The requests look like normal traffic. They come over HTTPS, they carry a real browser user agent, and the passwords are correct for other sites, so there is nothing in the payload to pattern-match on. What gives them away is volume and shape: one IP trying 400 accounts, or 400 IPs trying one account.

Rate limiting auth endpoints means counting attempts along two axes, per account and per source, and rejecting or slowing the ones that exceed a threshold. Below is the setup I use on a typical Node or Django backend: Redis counters, a sliding window, progressive delays, plus the details people forget like password reset and token refresh.

Why one limit is never enough

If you only limit by IP, a botnet with a thousand residential proxies gets a thousand attempts per window against the same account. If you only limit by account, a single IP can spray one attempt across ten thousand accounts and never trip the counter. You need both, and they should fail independently.

  • Per account: protects one user from being brute-forced. Threshold is low, maybe 5 to 10 failures in 15 minutes.
  • Per IP: protects the whole user base from one source. Threshold is higher, since offices and mobile carriers share addresses, maybe 30 to 50 failures in 15 minutes.
  • Per subnet or ASN: optional, but catches the case where someone rotates through a /24.

Count failures, not requests. A successful login should reset the account counter, otherwise a shared kiosk gets locked out by normal use. And key the account counter on the normalized email, lowercased and trimmed, not on the raw input, or User@x.com and user@x.com become separate buckets.

The counters, in Redis

A fixed window with INCR and EXPIRE is fine for this. It has the known burst-at-the-boundary flaw, but for login attempts that is an acceptable trade for the simplicity. Do both operations in one round trip so a crash between them does not leave a key with no TTL.

local count = redis.call('INCR', KEYS[1])
if count == 1 then
  redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return count

Call it with keys like rl:acct:user@x.com and rl:ip:203.0.113.9, both with a 900 second TTL. Check both before you even hash the password, so a blocked request costs you one Redis call and nothing else.

const acct = await bump(`rl:acct:${email}`, 900);
const ip = await bump(`rl:ip:${ip}`, 900);

if (acct > 10 || ip > 50) {
  res.set('Retry-After', '900');
  return res.status(429).json({ error: 'too_many_attempts' });
}

Return the same 429 body and timing whether the account exists or not. If a blocked response is faster or different for unknown emails, you have rebuilt the user enumeration bug you were trying to avoid.

Delays, lockouts and the endpoints you forgot

Hard 429s are clean but they tell the attacker exactly when they hit the wall. A short progressive delay on the first few failures blurs that and costs you almost nothing: sleep 200ms after the second failure, 500ms after the third, 1s after the fourth. Past the threshold, block. Keep the delay server-side; client-side timers do not survive a curl loop.

Account lockout is the part people get wrong. A permanent lock on the account is a denial-of-service tool: anyone can lock any user out by failing five times. Prefer a temporary cooldown, 15 minutes, and send the user an email so a real person knows. Do not lock, and do not reveal, on the first failure.

Then go find every other endpoint that takes credentials or issues them. Credential stuffing campaigns rarely stop at /login:

  • Password reset request, which also leaks whether an address is registered and can be used to spam a victim.
  • Registration, to stop bulk account creation.
  • OAuth token and refresh endpoints, especially the refresh grant.
  • MFA verification codes, where six digits is a small space and rate limiting is the only real defence.
  • Any legacy /api/v1/login you forgot was still routed.

If you are running this behind a load balancer or CDN, make sure X-Forwarded-For is trusted only from your edge, or attackers will spoof the header and get a fresh IP bucket per request. That is a config bug worth checking before you tune any thresholds. Teams that run this kind of hardening across several apps can also hand the auth layer to us rather than rebuilding it per project.

Finally, log every 429 with the account hash, the IP and the timestamp, and alert on the rate. A steady trickle of 429s from many IPs against many accounts is the signature of a distributed stuffing run, and it is the only signal you will get before accounts start getting taken over.

Frequently asked questions

Should I rate limit by IP or by account?

Both. An account limit stops one user being brute-forced, and an IP limit stops one source spraying many accounts. Either one alone is trivial to walk around, so check both counters before processing the login.

Will rate limiting lock out legitimate users?

It can if thresholds are too tight or if you count successful logins. Count failures only, reset the account counter on success, use a temporary cooldown rather than a permanent lock, and keep the IP threshold generous enough for shared office and carrier addresses.

Where should the counters live?

Redis or a comparable shared store, not in-process memory. If you run more than one app server, an in-memory counter gives each instance its own budget, so the effective limit is the threshold multiplied by the number of instances.


#Security