Home › Security › Credential Stuffing Defence — Rate Limits, MFA, Checks
Intermediate 5 min · September 23, 2026

Credential Stuffing Defence — Rate Limits, MFA, Checks

Credential stuffing replays breached passwords against your login.

N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Notes here come from systems that actually shipped.

Follow
✓ Production
production tested
September 26, 2026
last updated
2,085
articles · all by Naren
Before you start⏱ 17 min
  • ✓A login flow you operate or are building
  • ✓Access to authentication logs or analytics
  • ✓Basic familiarity with MFA options and password hashing
 ● Production Incident 🔎 Debug Guide
⚡Quick Answer
  • Credential stuffing replays username-password pairs from other sites' breaches, succeeding wherever users reused passwords
  • Rate-limit login attempts per account and per IP, but prefer progressive delays over hard lockouts that attackers abuse
  • Require MFA — ideally WebAuthn or passkeys — so a correct password alone never grants access
  • Screen new passwords against breach corpora with the k-anonymity API pattern, never sending full hashes anywhere
  • Alert on stuffing waves (spiky failures, password-spray usernames) and rehearse the playbook before the wave hits
✦ Definition~90s read
What is Rate Limiting and Credential Stuffing Defence?

Credential stuffing is an automated attack that replays stolen username-password pairs against login endpoints, exploiting password reuse across sites. Unlike brute force (guessing for one account) or spraying (common passwords across accounts), stuffing tries known-good pairs harvested from breaches and phishing kits.

★
Imagine thieves holding a ring of thousands of copied house keys, trying each one in your front door.

Each attempt uses a password that worked somewhere — the attacker learns where else when your login says yes.

The economics favor the attacker. Breach corpora containing billions of pairs circulate freely; botnets distribute attempts across thousands of IPs to evade simple rate limits; and headless browsers plus residential proxies make traffic look human. A 0.1–2% hit rate on ten million attempts means tens of thousands of takeovers per campaign.

Stolen sessions then fuel fraud, spam, and phishing from trusted accounts.

Rate limiting versus account lockout is the first design decision, and the naive answer backfires. Hard lockouts hand attackers a denial-of-service weapon: sprayed bad passwords lock out real users. Progressive delays, per-IP buckets, device fingerprinting, and step-up challenges slow automation without punishing legitimate typos.

The decisive layer is making passwords insufficient. MFA — a second factor the breach didn't include — stops stuffing cold for enrolled accounts, and WebAuthn/passkeys go further by binding authentication to your origin, defeating phishing too. Breach-corpus screening at registration and password change (via the k-anonymity pattern, which never transmits full hashes) rejects passwords already in attacker dictionaries.

Together: slow the tries, reject known-stolen secrets, and require proof beyond the password.

Plain-English First

Imagine thieves holding a ring of thousands of copied house keys, trying each one in your front door. They didn't pick your lock — they collected keys from doors owners lost, betting you used the same key as your neighbor. That's credential stuffing: attackers replay passwords leaked from other websites against yours. The defense isn't a stronger lock alone — it's limiting how fast anyone can try keys, demanding second proof of identity, and refusing keys already known copied.

Your login page works exactly as designed: it accepts correct passwords and rejects wrong ones. That correctness is the vulnerability. Attackers arrive holding millions of username-password pairs spilled from breaches elsewhere, and your login can't tell a legitimate user from a thief holding that user's reused password. Every attempt looks like an ordinary login, because cryptographically it is one.

Password reuse makes this devastating. People reuse passwords across dozens of sites, so a breach of a forum in 2019 becomes the key to bank, email, and shopping accounts in 2026. Automated tooling tries thousands of pairs per minute, distributed across botnets that dodge naive IP bans, and even a 0.1% success rate on a million-pair list yields a thousand compromised accounts.

Defending logins therefore means changing what 'correct' requires. Rate limiting and bot detection raise the cost of guessing, breach-corpus screening stops users choosing already-stolen passwords, and MFA — especially phishing-resistant WebAuthn — makes a stolen password insufficient by itself. This guide builds that layered defence: what stuffing looks like in your logs, which brakes actually work, and how to respond when the wave arrives.

Why Stolen Passwords Keep Working

Password reuse is the fuel and breach compilations are the match. The average person reuses a handful of passwords across dozens of accounts, and every breach — forums, games, shops, newsletters — adds millions of working pairs to attacker dictionaries. Compilations with billions of entries circulate permanently; a password spilled in 2019 is still being tried against new sites today, because nobody told the password it expired.

Stuffing tooling industrializes the trying. Credential lists feed automated runners that distribute attempts across residential proxies and botnets, solve basic CAPTCHAs, mimic real browsers, and rotate user agents — each IP staying under naive rate limits while the aggregate hammers your endpoint. Success rates look small (fractions of a percent) until you multiply by list sizes in the millions. Your login page's politeness — clear error messages, fast responses, no account enumeration care — helps legitimate users and attackers equally.

Accept the uncomfortable premise: some of your users' passwords are already public, and your login cannot distinguish those users from the thieves holding their credentials. Every defence in this guide follows from that premise. You slow the trying so campaigns cost more than they earn, you reject known-stolen secrets at the door, and you demand proof beyond the password so a correct guess still isn't enough.

📊 Production Insight
A site's 'helpful' error messages distinguished 'wrong password' from 'no such account.' Attackers enumerated valid usernames first, then stuffed only those — doubling their hit rate. Generic login errors are a real defence.
🎯 Key Takeaway
Reuse plus permanent breach compilations plus distributed tooling means some valid passwords are public. Design logins assuming attackers hold working credentials.

Rate Limiting vs Account Lockout: Picking the Right Brake

Not all brakes are safe to press. Hard account lockouts (N failures freeze the account) convert password guessing into account denial: attackers spray bad passwords across your user base and your own security control locks out thousands of legitimate users by morning. Unless paired with instant self-serve unlock (email link, verified device), lockouts are a weapon you hand to the attacker — most teams should avoid them as the primary control.

Progressive delays scale pain with suspicion instead: the first failures cost nothing, then each subsequent failure adds waiting time per account. Humans fat-fingering twice never notice; automation burning thousands of attempts bleeds hours. Layer per-IP buckets underneath for volumetric floods, device fingerprinting to separate known devices from new ones, and step-up challenges (CAPTCHA, then MFA) when velocity or anomaly scores cross thresholds. Each layer asks more of strangers while asking nothing extra of recognized users.

The snippet below shows the per-account progressive delay pattern — the single highest-value control for teams with only IP limiting today. Store attempt counters server-side with expiries, compute delay from consecutive failures, and enforce it before checking the password so timing reveals nothing. Pair it with alerting on aggregate failure ratios, because the delay slows attackers but it's your monitoring that ends campaigns.

auth/throttle.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import time
from functools import wraps

_attempts: dict[str, list[float]] = {}  # use Redis in production
WINDOW, BASE_DELAY = 900, 1.0  # 15-min window, 1s base

def login_delay(username: str) -> float:
    now = time.time()
    recent = [t for t in _attempts.get(username, []) if now - t < WINDOW]
    _attempts[username] = recent
    return min(BASE_DELAY * (2 ** len(recent)), 32.0)  # cap at 32s

def record_attempt(username: str, success: bool) -> None:
    if success:
        _attempts.pop(username, None)  # reset on good login
    else:
        _attempts.setdefault(username, []).append(time.time())

def throttle(username: str) -> None:
    delay = login_delay(username)
    if delay > BASE_DELAY:
        time.sleep(delay)  # slow automation, invisible to humans
📊 Production Insight
A team deployed hard lockouts and attackers locked 12,000 accounts overnight as retaliation for an IP ban. Support drowned for a week. Progressive delays plus step-up challenges replaced lockouts within a month.
🎯 Key Takeaway
Avoid hard lockouts as the primary brake — they enable denial of service. Use progressive per-account delays with step-up challenges for strangers.

MFA and WebAuthn That Survive Stolen Passwords

MFA is the control that makes stuffing mathematically pointless: the breach gave attackers passwords, not second factors. Authenticator apps (TOTP) and hardware keys stop virtually all replayed-password logins for enrolled accounts — the incident above would have been 4,100 failed attempts instead of 4,100 takeovers if those accounts had any second factor at all. The technology works; enrollment is the entire battle.

Not all second factors are equal. SMS codes are better than nothing but vulnerable to SIM swapping and interception; authenticator apps resist those but not real-time phishing (a fake login page relays the code in seconds). WebAuthn and passkeys bind the credential to your site's origin — the key simply won't answer a lookalike domain — defeating phishing and stuffing simultaneously. Prefer platform passkeys for consumers (zero hardware to ship) and hardware keys for admins and finance roles.

Drive enrollment like a product launch, not a suggestion. Require MFA for privileged actions from day one, prompt standard users at natural moments (first login, security settings visits), and celebrate progress with a visible enrollment KPI. For accounts that remain unenrolled, compensate with tighter anomaly thresholds and faster step-up challenges — and keep recovery flows strict, because attackers who can't stuff the front door will try the 'I lost my phone' window next.

📊 Production Insight
A company 'required MFA' but left the recovery flow as email-only password reset. Attackers stuffed the email accounts instead and walked through recovery. Harden recovery to the level of the login or MFA is decorative.
🎯 Key Takeaway
MFA makes stolen passwords useless; WebAuthn also defeats phishing. Make enrollment the default path and harden recovery flows equally.

Breach Monitoring with the K-Anonymity Pattern

If a user's new password already appears in breach compilations, attackers own it on day one — rejecting it at creation is pure prevention. The k-anonymity API pattern (pioneered by the haveibeenpwned password service) makes this check privacy-safe: hash the candidate password with SHA-1 locally, transmit only the first 5 hex characters, receive all matching suffixes, and compare locally. The service never sees the full hash, let alone the password, yet you learn exactly whether it's been seen in breaches and how often.

Apply the check at every password-set moment: registration, password change, and admin resets — plus periodic re-screening of existing hashes against fresh breach data, since today's safe choice becomes tomorrow's compilation entry. On a match, reject with actionable guidance: explain the password appeared in breaches, suggest a password manager, and offer generated alternatives. Never shame the user; the reuse habit is universal and the fix should feel helpful, not punitive.

The snippet below implements the client side of the pattern — note what's absent: no password leaves the machine, only a 5-character prefix. Cache responses briefly to survive API outages without blocking registration, and fail open carefully (log, allow, and re-check later) rather than failing closed on users during an outage. This single check would have forced resets for the 22,000 additional reused passwords found after the incident above — before attackers tried them.

auth/breach_check.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import hashlib
import urllib.request

def password_is_breached(candidate: str, timeout: int = 5) -> bool:
    digest = hashlib.sha1(candidate.encode()).hexdigest().upper()
    prefix, suffix = digest[:5], digest[5:]  # only prefix leaves us
    with urllib.request.urlopen(
        f"https://api.pwnedpasswords.com/range/{prefix}",
        timeout=timeout) as resp:
        suffixes = {line.split(":")[0] for line in
                    resp.read().decode().splitlines()}
    return suffix in suffixes  # full hash never transmitted

def validate_new_password(candidate: str) -> str | None:
    if len(candidate) < 12:
        return "Use at least 12 characters."
    if password_is_breached(candidate):
        return "That password appeared in breaches — pick another."
    return None  # acceptable
📊 Production Insight
A team sent full password hashes to a third-party 'breach check' API, trading one exposure for another. K-anonymity exists precisely so you never transmit the secret while checking it. Prefix only, always.
🎯 Key Takeaway
Screen every new password via k-anonymity: 5-char prefix out, suffix comparison local. Reject breached choices with helpful guidance.

Bot Signals and Step-up Challenges

Distributed stuffing looks human per-request and inhuman in aggregate — your detection must think in aggregates. Track failure ratios per hour (a jump from 5% to 30%+ is the classic wave signature), unique IPs per targeted account, impossible-travel logins, and datacenter or hosting-ASN origins for supposedly consumer logins. Device fingerprints and behavioral signals (typing cadence, mouse movement, TLS fingerprints) add per-request suspicion scores that feed step-up decisions.

Step-up challenges convert suspicion into cost without punishing real users. Low scores pass silently; medium scores get a CAPTCHA or email verification; high scores demand MFA or block pending review. The key property is asymmetry: each challenge costs automation real money (CAPTCHA solving, phone numbers, time) while costing recognized users nothing. Tune thresholds from logged outcomes — every false challenge teaches you about a legitimate pattern (travel, new phone, password manager autofill).

Keep a human in the loop for tuning, not for triage. Automated playbooks should freeze redemptions, force challenges, and page on-call when wave signatures fire; analysts then review aggregate dashboards, not individual logins. The bash probe below gives responders a one-command wave check: failure ratio plus IP spread over the last hour, the two numbers that distinguish stuffing from a bad deploy or a Monday morning.

BASH
1
2
3
4
5
6
7
# One-command stuffing wave check over the last hour of auth logs
# Log format assumed: ISO-time user ip result(success|failure)
awk '$1 > "$(date -u -d "1 hour ago" +%FT%T)"' /var/log/auth.log \
  | awk '{total++; if ($4=="failure") fail++; ips[$3]=1}
         END {printf "attempts=%d failures=%d ratio=%.1f%% unique_ips=%d\n",
               total, fail, (total?100*fail/total:0), length(ips)}'
# Wave signature: ratio >30% AND unique_ips in the thousands.
⚠ Never answer stuffing with user punishment
Mass lockouts and aggressive CAPTCHAs on every login hand attackers a denial-of-service win and train users to hate security. Challenge the suspicious, wave through the recognized, and freeze the money-moving actions first.
📊 Production Insight
A site answered a wave by CAPTCHA-ing every login for a week. Conversion dropped 18% and support tickets tripled — while attackers' solvers passed 90% of challenges. Targeted step-up would have cost them the same and users nothing.
🎯 Key Takeaway
Detect in aggregates (failure ratio plus IP spread), challenge by suspicion score, and automate the money-freeze before the human triage.

Alerting and Playbooks for Stuffing Waves

Waves arrive at 2 AM on holidays — your response quality equals your preparation, not your heroics. Define wave signatures as alerts, not observations: failure-ratio thresholds, IP-spread thresholds, impossible-travel volumes, and fraud-action spikes each page with runbook links attached. The runbook's first page is containment toggles (redemption freeze, global step-up, ASN blocks), because decision fatigue at 3 AM is how 9-hour incidents happen.

Rehearse the playbook quarterly with tabletop exercises: a simulated wave, a communications drill (who tells users, what the status page says, when regulators get notified), and a fraud-account recovery flow that returning victims can complete without calling support. Measure recovery experience like a product metric — victims who reset easily stay customers; victims who fight your process for a week leave and tell everyone.

After each wave, convert findings into permanent controls: new ASN blocks, tightened thresholds, expanded breach-screening, enrollment pushes for victim cohorts. The incident above ended with 22,000 preventive resets that should have been 22,000 rejections at password-set time. Every wave is a free penetration test of your login defences — grade it, fix the gaps it found, and make the next identical campaign fail silently at your front door.

monitoring/login-abuse-rules.yamlYAML
1
2
3
4
5
6
7
8
9
10
11
12
groups:
  - name: login-abuse
    rules:
      - alert: CredentialStuffingWave
        expr: |
          rate(auth_logins_failed_total[5m])
            / rate(auth_logins_total[5m]) > 0.30
        for: 10m
        labels:
          severity: page
        annotations:
          summary: 'Login failure ratio above 30% for 10m (possible wave)'
📊 Production Insight
A team contained a wave brilliantly but never told affected users for weeks, fearing bad press. Users discovered fraud themselves and churned at 3x the rate of promptly notified cohorts. Fast honest notification retains more trust than quiet competence.
🎯 Key Takeaway
Page on wave signatures with runbook-linked alerts, rehearse quarterly, and convert every wave's lessons into permanent login controls.
● Production incidentPOST-MORTEMseverity: high

900,000 Stuffing Attempts Took 4,100 Accounts in 9 Hours

Symptom
At 8:12 AM, fraud alerts flagged a 40x spike in gift-card redemptions overnight. Logs showed 900,000 login attempts between 11 PM and 8 AM from 18,000 IPs — each IP under the per-IP rate limit, each attempt a username-password pair from a two-year-old breach compilation. Roughly 4,100 logins succeeded (a 0.45% hit rate), and attackers changed account emails, drained loyalty points worth $96,000, and placed 1,700 fraudulent orders before the morning shift noticed. Legitimate users woke to locked-out accounts only where the fraud team had reacted manually.
Assumption
The team believed their per-IP rate limit (100 attempts per hour) stopped automation, not realizing distributed botnets split attempts so thinly that no single IP tripped it. They believed account lockout was 'too hostile to users' without implementing any alternative account-level brake. And they believed SMS codes offered 'for the security-conscious' were enough, leaving MFA optional — 97% of taken-over accounts had no second factor enrolled.
Root cause
Three missing layers compounded: no per-account attempt throttling or anomaly detection, so unlimited pairs could be tried against each username; no breach-corpus screening, so users held passwords that had been public for years; and optional-only MFA, so a correct reused password was sufficient for full access. The per-IP limit was the only control, and the botnet's 18,000 rotating IPs walked under it effortlessly.
Fix
Containment in 5 hours: force-reset all 4,100 accounts plus password matches against the breach corpus (another 22,000 forced resets), freeze gift-card redemptions, and block the 18,000 IPs plus the hosting ASNs at the WAF. Structural fixes over the next 3 weeks: per-account progressive delays with device-aware step-up MFA challenges, breach-corpus screening at registration and reset via k-anonymity, default-on authenticator MFA with WebAuthn encouraged, and a stuffing-wave runbook with automated redemption freezes on anomaly signals.
Key lesson
  • Per-IP limits alone can't see distributed attacks. Throttle per account, fingerprint devices, and challenge anomalies — never rely on IP counting.
  • Optional MFA protects almost nobody: 3% enrollment means the control exists on paper while 97% of accounts fall to reused passwords.
  • Screen passwords against breach data at every set-or-change moment. A password public for years should never be accepted as new.
Production debug guideFive defensive moves, from recognizing the wave to hardening logins.5 entries
Symptom · 01
You need to tell stuffing apart from normal login traffic
→
Fix
Look for the signature: failure rates jumping from a ~5% baseline to 30%+, attempts spread thinly across thousands of IPs, sequential usernames from breach lists, and logins succeeding from impossible-travel locations. Dashboard failed-login ratio per hour plus unique-IP counts — a wave shows both spiking together while per-IP volumes stay flat.
Symptom · 02
A wave is active right now and accounts are falling
→
Fix
Contain before hardening: force step-up MFA challenges on all anomalous sessions, freeze high-risk actions (redemptions, payouts, email changes) via feature flag, force-reset accounts with confirmed anomalous logins, and block the worst offending IP ranges and ASNs at the WAF. Prioritize stopping conversions (fraud actions) over stopping attempts — you can't out-block a botnet in real time.
Symptom · 03
Your only control is a per-IP rate limit
→
Fix
Add per-account progressive delays (1s, 2s, 4s...) plus device-aware step-up: unknown device plus correct password triggers an MFA or CAPTCHA challenge instead of access. Never implement hard lockouts without a self-serve unlock path — attackers weaponize lockouts into denial of service against your users. Log every challenge outcome to tune thresholds.
Symptom · 04
Users keep choosing passwords that are already breached
→
Fix
Integrate breach-corpus screening at registration, password change, and periodic re-checks using the k-anonymity pattern: hash the password with SHA-1 locally, send only the first 5 hex characters to the breach API, and compare the returned suffix list locally. Reject matches with guidance toward a password manager. Never transmit full passwords or full hashes to any third party.
Symptom · 05
MFA exists but almost nobody enrolls
→
Fix
Make enrollment the default path: prompt at first login after the fix, require it for privileged actions immediately, and offer WebAuthn/passkeys alongside authenticator apps. Track enrollment rate as a security KPI with a target above 80% for sensitive roles. Accounts that refuse MFA get tighter anomaly thresholds — unprotected accounts deserve extra suspicion, not equal treatment.
Stuffing Defences at a Glance
Root CauseHow to ConfirmFixPrevention
Unlimited tries per accountThousands of attempts against one username with no slowdownProgressive per-account delays with step-up challengesDevice-aware throttling plus wave-signature alerting
Hard lockouts weaponized into DoSMass lockout complaints during or after attack windowsReplace lockouts with delays and self-serve unlock pathsDesign review rule: no control attackers can trigger at scale
Breached passwords accepted as newUser passwords found in breach corpora after incidentsK-anonymity screening at every set-or-change momentPeriodic re-screening plus password-manager promotion
Password alone grants full accessTakeover accounts show no second factor enrolledDefault-on MFA with WebAuthn/passkeys preferredEnrollment KPI above 80%; hardened recovery flows
⚙ Quick Reference
4 commands from this guide
FileCommand / CodePurpose
auththrottle.pyfrom functools import wrapsRate Limiting vs Account Lockout
authbreach_check.pydef password_is_breached(candidate: str, timeout: int = 5) -> bool:Breach Monitoring with the K-Anonymity Pattern
awk '$1 > "$(date -u -d "1 hour ago" +%FT%T)"' /var/log/auth.log \Bot Signals and Step-up Challenges
monitoringlogin-abuse-rules.yamlgroups:Alerting and Playbooks for Stuffing Waves

Key takeaways

1
Stuffing replays real breached pairs; your login can't distinguish thieves from forgetful users.
2
Throttle per account with progressive delays
per-IP limits can't see distributed attacks.
3
Avoid hard lockouts as the primary brake; attackers weaponize them into denial of service.
4
Screen every new password with k-anonymity so already-stolen secrets are rejected at creation.
5
Make MFA (preferably WebAuthn/passkeys) the default so passwords alone never suffice.
6
Page on wave signatures and freeze money-moving actions first; rehearse the playbook quarterly.

Common mistakes to avoid

5 patterns
×

Relying on per-IP rate limits against distributed botnets

Symptom
18,000 IPs each stay under the limit while aggregate attempts hit a million overnight with no alert.
Fix
Throttle per account with progressive delays, fingerprint devices, and alert on aggregate failure ratios.
×

Using hard account lockouts as the primary brake

Symptom
Attackers spray bad passwords and your own control locks out thousands of legitimate users.
Fix
Replace lockouts with delays plus step-up challenges; any lockout needs instant self-serve unlock.
×

Leaving MFA optional and unenrolled

Symptom
3% enrollment means the control exists in docs while 97% of accounts fall to the first reused password.
Fix
Make enrollment the default path, require it for privileged actions, and track enrollment as a KPI.
×

Sending full passwords or hashes to breach-check APIs

Symptom
The breach check itself becomes a credential exposure to a third party.
Fix
Use k-anonymity: transmit only a 5-character hash prefix and compare suffixes locally.
×

Showing distinct errors for unknown users versus wrong passwords

Symptom
Attackers enumerate valid usernames first, then concentrate stuffing where it converts best.
Fix
Return identical generic login errors and constant-time responses for all failure cases.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
How does credential stuffing differ from brute force and password sprayi...
Q02SENIOR
Why are hard account lockouts dangerous as a stuffing defence?
Q03SENIOR
Explain the k-anonymity pattern for breach-corpus screening.
Q04SENIOR
What log signature distinguishes a stuffing wave from normal traffic?
Q05SENIOR
Design a login system resilient to stuffing, phishing, and lockout-DoS t...
Q01 of 05JUNIOR

How does credential stuffing differ from brute force and password spraying?

ANSWER
Stuffing replays known-good pairs from other breaches against many accounts; brute force guesses unknown passwords for one account; spraying tries a few common passwords across many accounts. Stuffing exploits reuse, so its attempts are cryptographically valid logins.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Can't CAPTCHAs stop stuffing on their own?
02
Is SMS MFA good enough against stuffing?
03
Should I notify users whose accounts were stuffed?
04
How do I avoid locking out legitimate travelers with anomaly detection?
05
Do password managers actually help against stuffing?
06
What should a stuffing runbook's first page contain?
N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Notes here come from systems that actually shipped.

Follow
✓ Verified
production tested
September 26, 2026
last updated
2,085
articles · all by Naren
🔥

That's Auth. Mark it forged?

5 min read · try the examples if you haven't

←
Previous
Secrets Leaked in Git History — Detect and Purge
5 / 5 · Auth
Next
Content Security Policy Blocked Inline Script
→