Breaking authentication
Credential stuffing, brute force, and the timing bug that leaks which emails have accounts, with the real fix from a login endpoint in production.

Most login forms get attacked by arithmetic, not cleverness. An attacker doesn't need to outsmart your auth logic. They need your endpoint to answer one question a little too honestly: did I just guess a real email address?
This lesson covers the two ways logins actually fail in practice, and fixes a real timing bug that's sitting in Linkstash's code right now.
Credential stuffing isn't guessing
Brute force, trying every password against one account, is slow and gets rate-limited fast. The attack that actually works at scale is credential stuffing: take a list of real email-and-password pairs leaked from some other breach, and try each pair against your login endpoint. It works because people reuse passwords. If someone's password leaked from a forum breach in 2023 and they use the same one on Linkstash, one request against your /auth/login finds it.
This is why rate limiting login attempts and requiring a second factor matter more than password complexity rules. The passwords being tried are often perfectly strong. They just aren't secret anymore, because they leaked somewhere else. Rate limiting is worth reading on its own, later in this series, but the short version here: a login endpoint with no limit on attempts per IP or per account is an open invitation to run someone else's leaked list against it.
The attack that doesn't need a single correct password
Here's the one that's easy to miss because it doesn't look like an attack at all. It just watches the clock.
Say Linkstash's login endpoint works like this: look up the email, and if a user exists, verify the password hash.
export async function verifyUser(db: Database, email: string, password: string) {
const row = db.prepare("SELECT id, email, passwordHash FROM users WHERE email = ?")
.get(email);
if (!row) {
return null; // fast path: no user, nothing to check
}
const ok = await argon2.verify(row.passwordHash, password);
return ok ? { id: row.id, email: row.email } : null;
}Every branch here looks correct. It returns 401 Unauthorized for both a wrong password and a nonexistent email, so the response body gives nothing away. But look at the two code paths again. A known email runs argon2.verify, which is deliberately expensive, tens of milliseconds by design, because that cost is what makes password cracking slow. An unknown email hits return null and finishes in well under a millisecond.
Time a hundred login attempts against real emails and a hundred against random ones, and the two groups separate cleanly on a stopwatch. You've built an account-enumeration tool without ever seeing a password fail or succeed. Once you can tell which emails have accounts, credential stuffing gets a lot more efficient: you only spend your leaked password list on addresses you know are real.
Same status code, different cost
A 401 on every branch feels like it closes the leak. It doesn't, because HTTP status codes aren't the only signal a response carries. Response time is data too, and it's the one people forget to control.
The fix Linkstash actually ships
The fix isn't complicated. It's making the missing-user path cost the same as the real one, on purpose:
/** A hash of a throwaway random secret, computed once at startup. verifyUser
* verifies against this when no user matches, so a login for an unknown email
* costs the same as one for a known email. Without it, response latency alone
* tells an attacker which addresses have accounts. */
const DUMMY_HASH = await argon2.hash(randomBytes(32).toString("hex"), { type: argon2.argon2id });
export async function verifyUser(db: Database, email: string, password: string): Promise<PublicUser | null> {
const row = db.prepare("SELECT id, email, passwordHash FROM users WHERE email = ?")
.get(email) as { id: number; email: string; passwordHash: string } | undefined;
if (!row) {
// Spend the same work as a real verification so the response time does not
// reveal whether the account exists. The result is deliberately discarded.
await argon2.verify(DUMMY_HASH, password);
return null;
}
const ok = await argon2.verify(row.passwordHash, password);
return ok ? { id: row.id, email: row.email } : null;
}DUMMY_HASH gets computed once, at startup, from a random value nobody's password will ever match. When the email doesn't exist, the function still runs a full argon2.verify against that dummy hash before returning null. The result is thrown away. The point isn't the answer, it's spending the same clock time either branch takes. An attacker timing responses now sees the same cost whether the email is real or not.
This is a small function with a comment explaining exactly why each line exists, which is worth noticing as a habit on its own: the code that prevents a subtle attack is also the code most likely to look deletable to someone who doesn't know the reason it's there. Leave the comment.
Quick check
Why does verifyUser run argon2.verify against DUMMY_HASH even when it already knows no user matched?
What to actually check
Two things, and they cover most of what turns a login form into an easy target:
- Response time, not just response body. If a security review only diffs JSON payloads between a valid and invalid login, it'll miss this every time. Time it.
- Attempt limits. Cap login attempts per account and per IP, and lock out or slow down past a threshold. Credential stuffing dies quickly against a real rate limit, no matter how good the leaked list is.
Neither of these is exotic. Both get skipped constantly because a login form that returns the right status codes looks finished.
The next lesson goes one layer deeper into the same function: what should actually be stored in passwordHash, why 12 characters beats a complexity rule, and a signup bug that only shows up under concurrent load. Continue to why your password hash is not good enough.
If you haven't yet, cookies and sessions covers what happens right after a login succeeds, which this lesson deliberately left alone.

Written by
Rhythm Bhiwani
Engineer and relentless builder, happiest reverse-engineering hard problems until they click.
Enjoyed this?
Tap the heart to leave some love.
Be the first to react
Comments
Join the conversation.
Loading comments…


