Our services
If you can think it, we can make it brainsoft.
If you can think it, we can make it brainsoft.
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.
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.
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.
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.
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:
/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.
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.
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.
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.