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.

Which Counter Sees Which Attack A matrix of three counters against two attack shapes. Targeted guessing against one account trips the per-credential counter and possibly the source counter. A distributed campaign of two attempts per account across many accounts and many addresses trips neither, and is visible only in the aggregate failure ratio for the endpoint. Targeted guessing one account, many passwords Credential stuffing many accounts, 2 tries each Per credential trips quickly never trips — 2 of 10 used Per source address trips if the host is noisy never trips — addresses rotate Global failure ratio barely moves rises sharply — the only signal

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.

Three Postures, Driven by One Signal Normal posture applies the standard budgets. Elevated posture challenges logins from unrecognised devices and networks, which collapses an attacker success rate while regular users on familiar devices notice nothing. Lockdown requires a second factor from everyone and pages the on-call engineer. All three transitions are automatic. Normal — standard budgets apply the failure ratio sits at its usual baseline for this endpoint Elevated — challenge unrecognised devices and networks the attacker holds none of the victim devices, so their success rate collapses Lockdown — a second factor from everyone, and a page automatic, because a campaign starting at 3am should not wait for 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.

Where the Timing Difference Comes From Without a dummy hash, an unknown account skips the password verification entirely and answers in a couple of milliseconds, while a real account pays the full memory-hard cost. That difference enumerates the user base without a single successful login. Verifying against a constant dummy hash and padding to a fixed floor makes the two indistinguishable. Unknown account, no dummy hash — about 2 ms no verification runs, because there is nothing to verify against Real account, wrong password — about 250 ms the full memory-hard verification runs, as it should Both paths, with a dummy hash and a time floor — about 260 ms the same work, the same padding, and no signal to measure

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.