Credential Stuffing
Why password reuse is catastrophic — and the four defenses that actually move the needle.
Credential Stuffing
Credential stuffing is the most successful authentication attack on the modern web — and the one most teams least understand because it doesn't look like an attack. The attacker isn't guessing your users' passwords. The attacker already has their passwords, from somebody else's breach, and is just typing them into your login form to see which ones still work.
How it actually works
The economics are simple. A breach corpus — say the 2019 "Collection #1" with 773 million email/password pairs — is freely available. Roughly 0.5% of those credentials still work somewhere they're reused. The attacker writes a script (or uses an off-the-shelf tool like OpenBullet, Sentry MBA, or BlackBullet) that:
- Loads a list of email/password pairs.
- POSTs each pair to your
/loginendpoint. - Records the ones that succeed — those accounts are now compromised.
- Either uses them directly (e.g. drains gift-card balances, posts spam) or sells the working set on a forum at $0.50 per account.
To dodge naive rate-limiting, the script runs through a residential proxy network — services like Bright Data and Smartproxy rent IPs from millions of real consumer routers. Your /login traffic looks like 200,000 different households trying once each. There is no IP to block.
Why password reuse is the root cause
Stuffing works because passwords are still mostly reused. Around 60% of users reuse the same password across at least 5 sites; about 25% reuse one password everywhere. So when LinkedIn leaks, every site those users use is now exposed to the same credentials.
Killing reuse globally is impossible. But you can do two things on your side:
- At signup and password change, refuse passwords that appear in breach corpora. The user picks a different one — one that's not already in attacker hands.
- Make a successful stuffing log-in worthless — require a second factor that the attacker doesn't have.
The four defenses
In rough order of impact:
1. MFA — by far the highest leverage
A working password no longer logs the attacker in. They get a TOTP prompt; they don't have the device; they're done. Microsoft published numbers in 2019 showing MFA blocks > 99.9% of automated attacks. Even SMS-OTP MFA — far from perfect — still kills 99% of stuffing because attackers can't pivot to SIM-swap at scale.
If you only do one thing on this list, it's this.
2. Breach-password screening (HIBP-style)
At signup and password change, ask the user's chosen password against Have I Been Pwned's Pwned Passwords — a corpus of 800M+ passwords from public breaches. The k-anonymity API lets you do this without the password ever leaving the client:
SHA1("hunter2") = F3BBBD66A63D4BF1747940578EC3D0103530E21D
└prefix┘ └────────── suffix ──────────┘
GET https://api.pwnedpasswords.com/range/F3BBB
→ list of suffixes that share that prefix, with breach counts
D66A63D4BF1747940578EC3D0103530E21D:30000
...
→ if your suffix is in the list, refuse the password.
The API is free, requires no key, and the server never sees the full hash. There is no excuse to skip this.
3. Rate-limiting that's actually useful
Rate-limit per-IP and per-account, with sliding windows. The per-account counter survives the residential proxy problem — the attacker still concentrates failures on alice@example.com from many IPs.
- /login per-account: 5 failed attempts in 15 minutes → exponential backoff lockout.
- /login per-IP: 30 attempts in 1 minute → 429.
- /login global: anomaly threshold, alert on >10× baseline rate.
Don't trust client IPs without verifying your proxy chain. Don't ban indefinitely — temporary lockouts let legitimate users come back; permanent bans are an attack surface (deny-of-service against any account whose email is known).
4. Login-anomaly detection
Score every login attempt with a few cheap signals:
- Country differs from recent sign-ins.
- Device fingerprint differs from the trusted set.
- Login time of day is outside the user's typical window.
- IP appears on Spamhaus / FireHOL / known-proxy lists.
- Recent failed-login burst on this account.
Above a threshold, step up to MFA challenge even if the user has "remember this device" set. Below the threshold, allow the cookie. This is what every consumer bank and every major SaaS does.
What does NOT work
- CAPTCHA at the login form. Modern stuffing tools route CAPTCHAs through human-solving services at $1 per 1000 — economically a rounding error. Use CAPTCHA only as a friction layer above anomaly detection, not as a primary defense.
- Banning short passwords without breach screening. Short passwords are not the same as breached passwords.
correcthorsebatterystapleis 28 characters and in breach corpora. - "We hash with bcrypt so we're fine." Hashing protects you when your database leaks. It does nothing about credentials reused from other breaches.
- Username enumeration paranoia. "Don't tell the attacker which usernames exist" is fine to do, but does ~nothing against a stuffing list that already has working email addresses with passwords attached.
What ATO looks like in your logs
When stuffing is happening, the shape is unmistakable:
- Login-success rate drops from 95% to 70%.
- Failed-login count spikes by 100×.
- Unique source IPs spike (residential-proxy fan-out).
- Most failures cluster on accounts with email addresses that appear in known breach lists.
- A small but elevated fraction of successes comes from new countries / devices.
Set alarms on these. Don't wait for users to call support saying "I got locked out and there's a $200 charge in my account."
Code shape that gets it right
async function login(email: string, password: string, ctx: LoginCtx) {
// Per-account + per-IP rate limit
await rateLimit(`login:account:${email}`, { max: 5, windowMs: 900_000 });
await rateLimit(`login:ip:${ctx.ip}`, { max: 30, windowMs: 60_000 });
const user = await findUser(email);
if (!user || !(await argon2.verify(user.hash, password))) {
await audit({ action: "auth.login", actor: email, metadata: { ok: false, ip: ctx.ip } });
throw new Error("Invalid credentials");
}
const score = await scoreLogin(user, ctx); // anomaly score
if (score >= 50 || user.mfaEnabled) {
return { needsMfa: true, challengeId: await issueMfaChallenge(user) };
}
return { sessionId: await issueSession(user, ctx) };
}
That's the shape. The exact thresholds, scoring algorithm, and rate-limit windows are tuneable — but the structure (rate-limit → verify → score → step-up) is what stuffing-resistant looks like in practice.
Reality check
If you're protecting an asset worth attacking — payments, identity, a market with significant fraud — you will be stuffed. Every login system at scale handles tens of millions of stuffing attempts per month and treats it as background noise. Build for that, not for "we don't think this'll happen to us."
Tools in the wild
5 tools- serviceHIBP — Pwned Passwords APIfree tier
k-anonymity API for breach-password screening — free, no API key required.
- service
ML-driven bot detection and rate-limiting at the edge — primary stuffing defense for many sites.
- service
Account-takeover prevention rule group + rate-based rules.
- libraryexpress-rate-limitfree tier
Per-route rate-limiting middleware for Express; stuffing's first line of defense.
- specOWASP CSPv1free tier
OWASP's credential-stuffing project — defenses, threat model, and metrics.