Why your password hash is not good enough
argon2id versus faster hashes, why a 12-character minimum beats complexity rules, and a real race condition in a signup endpoint under concurrent load.

"We hash passwords" isn't a security decision, it's a category. MD5 is a hash. So is SHA-256. So is argon2id. Two of those three will get every password in your database cracked within days of a leak, and it isn't the one you'd guess first.
The last lesson fixed a timing bug in Linkstash's login check. This one covers what happens on the other side of that check: how the password actually gets stored, and a second bug in the signup path that only shows up when two requests land at the same time.
Fast hashes are the whole problem
MD5 and SHA-256 were built to be fast, because they're general-purpose hash functions used for things like file integrity, where speed is the point. Speed is exactly what you don't want for a password hash. A modern GPU can compute billions of SHA-256 hashes per second, which means an attacker with a leaked hash database isn't guessing passwords one at a time. They're testing a dictionary of billions of candidates against every hash, in parallel, in hours.
argon2id is built the opposite way: deliberately slow, and deliberately memory-hungry, which is the part that actually matters. GPUs win at fast, cheap-per-hash workloads. They lose that advantage against a function that also needs a chunk of memory per attempt, because you can't just add more parallel compute to work around a memory requirement. That asymmetry is the entire reason argon2id (winner of the 2015 Password Hashing Competition) is the current recommended default, ahead of older options like bcrypt and scrypt.
Here's the real call in Linkstash:
export async function registerUser(db: Database, email: string, password: string): Promise<PublicUser> {
const passwordHash = await argon2.hash(password, { type: argon2.argon2id });
// ...
}One line, but it's the line that decides whether a future database leak is a five-alarm fire or a background chore. Nothing about password itself is ever stored. passwordHash is a one-way function of it, computed with a memory-hard algorithm that's slow on purpose, on both sides: yours and any attacker's.
Length beats character-class rules
Linkstash requires a 12-character minimum on signup and nothing else:
const credentials = z.object({
email: z.email(),
password: z.string().min(12, "Use at least 12 characters"),
});No forced uppercase letter, no mandatory symbol, no digit requirement. That's deliberate, not an oversight. The classic "one uppercase, one number, one symbol" rule optimizes for a password that looks complex, and in practice people satisfy it the same way every time: Password1!. That's a rule followed, and it's also one of the first guesses in any real cracking dictionary.
Length works differently. Every additional character multiplies the total number of guesses an attacker has to try, whether or not the password uses any symbols at all. correcthorsebatterystaple at 25 characters, plain lowercase letters, is dramatically harder to brute-force than P@ssw0rd! at 9 characters, despite failing every character-class rule you'd normally enforce. This is exactly the case the xkcd 936 comic and NIST's SP 800-63B guidelines both make: length is the lever that actually moves difficulty, complexity rules mostly move where users write their passwords down.
Quick check
Why does a 12-character minimum with no character-class rules beat a shorter password that requires uppercase, a digit, and a symbol?
The bug that only shows up under load
Here's the part of registerUser that's easy to write wrong, and Linkstash's version looks wrong on first read:
export async function registerUser(db: Database, email: string, password: string): Promise<PublicUser> {
const passwordHash = await argon2.hash(password, { type: argon2.argon2id });
try {
const { lastInsertRowid } = db
.prepare("INSERT INTO users (email, passwordHash) VALUES (?, ?)")
.run(email, passwordHash);
return { id: Number(lastInsertRowid), email };
} catch (err) {
// The UNIQUE index on users.email is the only reliable guard. A check-then-insert
// can be overtaken by a concurrent signup between the two statements, so the
// constraint violation is what we translate, not a prior SELECT.
if (err instanceof Error && /UNIQUE constraint failed: users\.email/i.test(err.message)) {
throw new EmailTakenError(email);
}
throw err;
}
}The obvious way to write "reject duplicate emails" is a SELECT to check if the email exists, then an INSERT if it doesn't. Linkstash doesn't do that. It just tries the insert and catches the failure. That looks backwards until you think about two signups landing close together.
Say Diya and someone spoofing Diya's email both submit POST /auth/register for diya@example.com within a few milliseconds of each other. With a check-then-insert:
Both requests run their SELECT before either one's INSERT lands. Both see "no existing user." Both proceed to insert. Now there are two accounts sharing one email address, and which password logs in as which becomes genuinely unpredictable. This is a real race condition, not a hypothetical one. It needs concurrent load, which is exactly when nobody's watching a staging environment closely.
Linkstash's fix removes the gap entirely. The database's UNIQUE index on users.email is the actual guard, checked atomically by SQLite as part of the insert itself. There's no window between "check" and "write" for a second request to slip through, because there's only one statement. The try/catch isn't there to handle an error case someone forgot to prevent. It's there to translate a constraint violation SQLite already enforces into a 409 EmailTaken a client can read.
The general pattern
Any time you write "check if X, then do Y if not," ask whether the database can enforce X as a constraint instead. A UNIQUE index, checked atomically on write, closes race conditions that application-level SELECT-then-INSERT logic cannot, no matter how quickly you run the check.
What to carry forward
Two independent decisions, and Linkstash gets both right: argon2id because speed is the attacker's friend, and a 12-character minimum because length beats rules nobody follows honestly. The registration race condition is the less obvious lesson, but it's the one worth remembering next time "just check first" looks like the simple answer. Sometimes the database already knows how to check for you, atomically, and doing it yourself just adds a gap.
Next: broken access control and IDOR, where the bug isn't in who can log in, it's in what a logged-in user is allowed to touch.

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…


