Rate Limiting Login Endpoints Against Credential Stuffing
Credential stuffing is not a brute-force attack and does not behave like one. The attacker already has valid passwords — from someone else’s breach — and only needs to find where they were reused. Two or three attempts per account across a very large list is enough, which means every per-account counter stays comfortably under its threshold while the campaign quietly succeeds against the small fraction of users who reused a password.
Throttling that stops this looks different from ordinary rate limiting. This guide configures it: per-credential counters, a global failure-ratio trigger that sees the aggregate, uniform responses that leak nothing, and escalation to an additional factor rather than a lockout an attacker can weaponise. It is part of the API Rate Limiting & Abuse Prevention guide within Vulnerability Patterns & Web Mitigation Strategies, and it is the throttling half of MFA enforcement patterns.
Prerequisites
- A login endpoint whose failures you can count centrally
- A shared counter store reachable from every instance
- A step-up mechanism already implemented — an additional factor, an emailed code, or a challenge
- Baseline metrics: current failure ratio and the failed-attempt distribution before successful logins
Expected Outcomes
- Failures counted per submitted credential, per source, and globally across the endpoint
- The endpoint tightening automatically when the aggregate failure ratio rises
- Identical responses for existing and unknown accounts at every stage
- Escalation to an additional factor rather than an attacker-triggerable lockout
Step 1: Count Failures Against Three Keys at Once
A single counter is always the wrong one. Count three ways and refuse when any is exceeded.
const KEYS = (email: string, ip: string) => ({
credential: `login:cred:${sha256(normaliseEmail(email))}`, // hashed: the store holds no addresses
source: `login:ip:${ip}`,
global: `login:global`,
});
export async function checkLoginBudget(email: string, ip: string) {
const k = KEYS(email, ip);
const [cred, src] = await Promise.all([
limiter.peek(k.credential, { window: 900, max: 10 }),
limiter.peek(k.source, { window: 900, max: 50 }),
]);
const ratio = await failureRatio(); // rolling 5-minute aggregate
return {
allowed: cred.remaining > 0 && src.remaining > 0,
escalate: ratio > BASELINE_RATIO * 3, // stuffing signal
};
}
The credential key is the one that catches targeted guessing against one account. The source key catches a single noisy host. Neither sees a distributed campaign — that is what the ratio is for.
Step 2: Trigger on the Aggregate, Not on Any One Key
The ratio of failed to successful logins across the endpoint is remarkably stable in normal operation, which makes a deviation a high-quality signal.
def failure_ratio(window_seconds: int = 300) -> float:
failed = redis.get("login:failed:5m") or 0
ok = redis.get("login:ok:5m") or 0
return int(failed) / max(int(failed) + int(ok), 1)
def posture() -> str:
ratio = failure_ratio()
if ratio > BASELINE * 5: return "lockdown" # every login requires a second factor
if ratio > BASELINE * 3: return "elevated" # new devices and new networks are challenged
return "normal"
Under elevated, require a challenge for logins from unrecognised devices — the attacker has none of the victim’s devices, so their success rate collapses while regular users on familiar devices notice nothing. Under lockdown, require a second factor from everyone and alert the on-call engineer. Both postures are automatic, because a campaign that starts at three in the morning should not wait for a human to notice a dashboard.
Step 3: Keep Every Response Identical
An attacker learns as much from how you refuse as from whether you refuse.
// Constant work regardless of whether the account exists.
const user = await users.findByEmail(email); // may be null
const hash = user?.passwordHash ?? DUMMY_HASH; // a real hash of a random value
const ok = await argon2.verify(hash, password); // always runs, always costs the same
if (!ok || !user) {
await recordFailure(email, ip);
await sleepToConstantTime(started); // pad to a fixed floor
return res.status(401).json({ error: 'invalid_credentials' });
}
The dummy hash matters more than it looks. Without it, an unknown account returns in two milliseconds and a real one in three hundred, and that difference alone enumerates your entire user base without a single successful login. Pad the total handler time to a fixed floor so throttled, failed and unknown paths are indistinguishable from the outside.
Verification
# 1. Timing must not distinguish a real account from an unknown one.
for EMAIL in [email protected] nobody-$RANDOM@example.com; do
for i in 1 2 3; do
curl -s -o /dev/null -w "$EMAIL %{time_total}\n" -X POST http://localhost:3000/auth/login \
-H 'Content-Type: application/json' -d "{\"email\":\"$EMAIL\",\"password\":\"wrong\"}"
done
done
# Expected: all timings within a few milliseconds of one another.
# 2. The credential budget trips at the same attempt count for both.
# (Run the loop for each address and compare the attempt number of the first 429.)
# 3. The ratio trigger escalates.
seq 500 | xargs -P20 -I{} curl -s -o /dev/null -X POST http://localhost:3000/auth/login \
-H 'Content-Type: application/json' -d '{"email":"u{}@example.com","password":"x"}'
curl -s http://localhost:3000/internal/auth-posture # expect "elevated" or "lockdown"
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Support tickets about locked accounts | Hard lockout rather than escalation | Replace lockout with a step-up challenge; keep lockout only for confirmed compromise |
| Attack continues despite throttling | Only per-source and per-account counters exist | Add the global failure-ratio trigger and automatic posture change |
| Response times differ for unknown accounts | No dummy hash on the not-found path | Verify against a constant dummy hash and pad to a fixed time floor |
| Legitimate users throttled on shared networks | Source counter too tight for corporate egress | Loosen the source limit and rely on credential and global layers |
| Ratio trigger fires during a deploy | Client retry storm counted as failures | Exclude transport errors from the failure count; only count credential rejections |
| Counters reset unexpectedly | Store restart with no persistence | Enable persistence, or accept the reset and alert on it |
Common Implementation Mistakes
Frequently Asked Questions
Why not simply lock the account after five failures?
Because a lockout an attacker can trigger is a denial-of-service feature. Anyone who knows an email address can lock that account by submitting wrong passwords, which converts your security control into their weapon and fills your support queue. Escalate instead — require an additional factor, add a delay, demand a proof of work — so a legitimate user with the correct password can still get in while guessing becomes expensive.
Does a CAPTCHA solve credential stuffing?
It raises the cost per attempt, which does matter, and it is routinely outsourced by attackers for a fraction of a cent per solve. Deploy it as an escalation triggered by the failure-ratio signal rather than a permanent tax on every legitimate login, and never treat a solved challenge as evidence that the caller is genuine. The credential check and the throttle still have to do their jobs.
How do I throttle without a user-visible slowdown?
Nearly all legitimate users authenticate within one or two attempts, so a budget of ten failures per fifteen minutes is invisible to them while cutting an attacker’s throughput by orders of magnitude. Measure the distribution of failed attempts preceding successful logins in your own traffic, set the ceiling above the ninety-ninth percentile of that distribution, and you will have a limit that is generous for humans and hostile to scripts.
Related
- API Rate Limiting & Abuse Prevention — the parent guide covering identities, algorithms and failure modes
- Token Bucket vs Sliding Window Rate Limiting — why authentication needs a sliding window
- MFA Enforcement Patterns — the escalation this page triggers
- Breached Password Screening With k-Anonymity — removing the reused passwords stuffing depends on