How Passwords Are Actually Attacked
Offline guessing against a stolen table, online credential stuffing with passwords from other breaches, and targeted attempts against one account — three different attacks with three different defenses, only one of which is the hash function.
Frame the problem
Security starts with a concrete asset, attacker capability and trust crossing.
Three attacks, three defenses
Offline cracking happens after a database disclosure. There is no server involved, so rate limits, MFA prompts, lockouts and alerts are all irrelevant — the attacker computes candidates on their own hardware, in parallel, for as long as they like. The only defense that exists at this point is the cost of computing one candidate hash, which is decided entirely by the work factor chosen in Password Storage. This is why that choice matters so much: it is the *only* control in this scenario.
Credential stuffing is the most common real attack on login endpoints and it is not guessing at all. The attacker takes username/password pairs from breaches of *other* services and replays them against yours. Because password reuse is widespread, a small single-digit percentage succeeds — and at a million attempts, that is tens of thousands of accounts. Rate limiting per IP is nearly useless against it, because the traffic is distributed across residential proxies with one attempt per address. The defenses that work are MFA (the password being correct is no longer sufficient), breached-password checks (the pair never works because you rejected that password at signup), and device- and behaviour-based risk scoring.
Targeted brute force against one account is the rarest and the easiest to stop: per-account rate limiting with exponential backoff, and MFA. The important design detail is to rate-limit per *account*, not only per IP, since an attacker rotating addresses defeats the second while the first still holds.
| Defense | Offline cracking | Credential stuffing | Targeted brute force |
|---|---|---|---|
| Slow, memory-hard hash | The only thing that helps | No effect — password is already known | Minor: slows attempts server-side |
| Rate limit per IP | No effect | Weak — distributed sources defeat it | Weak — attacker rotates addresses |
| Rate limit per account | No effect | Weak — one attempt per account | Strong |
| MFA | No effect on the hash, blocks use | Strong | Strong |
| Breached-password check | Fewer weak candidates match | Strong — the pair never works | Moderate |
| Device / risk signals | No effect | Strong | Moderate |
| Account lockout | No effect | Harmful — enables denial of service on users | Moderate, with care |
Why the offline scenario dominates the design
Reasoning about relative effort is more useful than any specific number, because hardware moves and published rates go stale. The structure is what matters: a general-purpose hash is designed for throughput, so an attacker with parallel hardware evaluates it at an enormous rate. A dedicated password-hashing function is designed to resist exactly that — it is slow by construction and, in the memory-hard case, requires a substantial amount of memory per evaluation, which is the resource that parallel hardware cannot cheaply multiply.
The practical consequence is a change in *which passwords survive*. Against a fast hash, everything up to a fairly long human-chosen password eventually falls, and the common ones fall immediately. Against a tuned memory-hard function with the same attacker budget, only the passwords that were already in a common list fall, and the rest are out of reach for the lifetime of the data. You have not made cracking impossible; you have moved the boundary of what is economical, which is exactly what a work factor is for.
This is also why the number should be re-tuned. The attacker's hardware improves every year; your work factor does not, unless someone raises it. Treat it as a parameter with an owner and a review date, in the same way as a certificate expiry.
Rate limiting that helps rather than hurts
The naive control — lock the account after five failures — creates a denial-of-service primitive: anyone who knows a username can lock that user out on demand. For a consumer product this is an abuse channel; for a business tool it is an outage. Prefer graduated friction over hard lockout.
A workable design: count failures per account and per source independently. Introduce increasing delay rather than refusal, so an automated attacker is slowed while a human retrying their own password is barely affected. Escalate to a challenge (CAPTCHA, email confirmation, MFA prompt) rather than to a block. Reset counters on a successful login *from a recognised device*, so an attacker's failures do not clear when the real user logs in normally.
And accept the limits of the mechanism honestly. Rate limiting is an abuse control, not a security boundary — see Rate Limiting as a Security Control. A distributed stuffing campaign with one attempt per account per address per hour looks exactly like normal login traffic in aggregate. Against that, only MFA and credential-quality controls change the outcome, which is why they belong in the design rather than in a follow-up ticket.
1async function loginAttempt(email: string, password: string, ctx: RequestCtx) {2 const acct = await failures.forAccount(email) // failures in the last hour3 const src = await failures.forSource(ctx.ip)4 5 // Escalate friction rather than refusing outright: an attacker is slowed,6 // a human who mistyped their password is not locked out of their account.7 if (acct >= 10 || src >= 50) await requireChallenge(ctx) // CAPTCHA or emailed confirmation8 else if (acct >= 3) await sleep(Math.min(2000, 250 * 2 ** (acct - 3)))9 10 const user = await users.byEmail(email)11 // Hash even when the account does not exist, so timing does not reveal existence.12 const ok = user ? await verifyPassword(password, user.hash) : await verifyPassword(password, DUMMY_HASH)13 14 if (!ok) {15 await failures.record(email, ctx.ip)16 metrics.increment('login.failure', { known: Boolean(user) })17 return { ok: false, message: 'Incorrect email or password.' } // identical for both cases18 }19 20 // Only clear counters for a device we have seen succeed before, so an attacker's21 // failure count is not reset by the real user logging in from their laptop.22 if (await devices.isKnown(user.id, ctx.deviceId)) await failures.clear(email)23 return { ok: true, user }24}Key points
- Offline cracking has exactly one defense: the work factor of the password hash. Nothing server-side applies.
- Credential stuffing is replay of known-correct passwords, so rate limits barely help and MFA plus breach checks do.
- Rate limit per account as well as per source; per-source alone is defeated by distributed proxies.
- Hard lockout creates a denial-of-service primitive; escalate friction and challenges instead.
- Hash even for non-existent accounts so response timing does not reveal which addresses are registered.
Boundary control exercise
This lesson uses the shared boundary-control exercise.
Follow the attack
Safe conceptual simulation: capability → missing control → crossed boundary → asset impact.
- 1Attacker → acquire pairs: download breach corpora containing email/password pairs from unrelated services.
- 2Pairs → distribute: spread attempts across residential proxies at one attempt per account per address.
- 3Attempts → successes: a small percentage works because users reuse passwords; volume makes it worthwhile.
- 4Successes → monetise: drain stored value, harvest data, or add persistence and sell access.
- Thousands of legitimate accounts compromised without a single vulnerability in your code.
- Support and fraud costs that scale with success rate, plus users who blame you for a password they reused.
- Login infrastructure load that can itself become an availability incident.
Defend, detect, recover
One prevention is a single point of security failure. Layer it and make failure observable.
- • Require MFA, ideally phishing-resistant, at least for accounts with meaningful value.
- • Reject known-breached passwords at registration and change time, and re-check periodically against updated corpora.
- • Use a tuned memory-hard hash so a future disclosure does not add to the corpus that attacks everyone else.
- • Apply graduated friction per account and per source, with challenges rather than lockouts.
- • Alert on an elevated login failure *rate* with a low failure-per-source ratio — the signature of distributed stuffing.
- • Alert on a rise in successful logins from new devices and new geographies, which is what a successful campaign looks like.
- • Track the ratio of failed to successful logins as a baseline metric; stuffing shifts it visibly before anyone notices individual accounts.
- • Force a password reset for accounts that succeeded from suspicious sources, and revoke their sessions.
- • Temporarily require an additional factor or challenge for all logins during an active campaign.
- • Check the successful accounts for persistence — new tokens, changed recovery email, new OAuth grants.
- • Users reuse passwords and will continue to; the credential is effectively public for some fraction of your users at all times.
- • Sophisticated campaigns mimic legitimate traffic closely enough to evade aggregate detection.
- • Challenges add friction that costs conversions, so the control has a business ceiling.