User accounts and password hashing done right
Why registerUser inserts first and catches the UNIQUE violation instead of checking first, and why verifyUser hashes a dummy password on every failed login.

Two lines of reference/linkstash/src/auth.ts look almost too simple to matter, and both are the kind of thing that only reveals what it's protecting against once you see the alternative fail. This is the worked example for the whole series: not "how do you hash a password," which is one function call, but "what does a login system get wrong when it looks correct."
Hashing: argon2id, not a homemade scheme
export async function registerUser(db: Database, email: string, password: string): Promise<PublicUser> {
const passwordHash = await argon2.hash(password, { type: argon2.argon2id });
// ...
}argon2.hash never stores the password itself, only a hash, and Argon2id is the current recommended choice specifically because it's memory-hard: cracking it by brute force needs real memory per guess, not just CPU cycles, which makes cheap parallel hardware (the kind that makes brute-forcing a fast hash like plain SHA-256 practical) far less useful against it. That's why Linkstash's credentials schema also requires a 12-character minimum, not the traditional 8: length beats character-class rules (one uppercase, one digit, one symbol) for the actual math of how long a real attack takes, and it's a rule far fewer people find annoying to follow.
The registration race, and why it's not check-then-insert
Here's the part that looks wrong on a first read, and is actually the correct version:
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;
}
}There's no SELECT ... WHERE email = ? anywhere before the insert. Most first attempts at this function look for the existing user first, then insert if none is found, and that order is the bug. Two requests can both run the SELECT, both find nothing, and both proceed to insert, because nothing about a separate check-then-insert stops a second request from slipping in between the two steps. Whichever insert loses the race either creates a duplicate account or crashes with a raw database error the caller never expected, depending on the schema.
registerUser sidesteps the race entirely by not racing at all. It just inserts, every time, and lets the database's own UNIQUE constraint on users.email (declared in db.ts's schema) be the single source of truth. If a second concurrent signup for the same email arrives a millisecond later, its insert fails with a constraint violation, caught here and translated into a clean EmailTakenError, which the route turns into 409 EmailTaken. One check, enforced by the database itself, and it can't be raced because the database serializes writes to the same row.
The test suite proves this directly, not just in theory:
it("maps a concurrent duplicate registration to EmailTakenError, not a raw driver error", async () => {
const results = await Promise.allSettled([
registerUser(db, "diya@example.com", "correct horse battery"),
registerUser(db, "diya@example.com", "correct horse battery"),
]);
expect(results.filter((r) => r.status === "fulfilled")).toHaveLength(1);
const rejected = results.filter((r) => r.status === "rejected") as PromiseRejectedResult[];
expect(rejected).toHaveLength(1);
expect(rejected[0]!.reason).toBeInstanceOf(EmailTakenError);
});Two registerUser calls fired at once, for the same email, with Promise.allSettled. Exactly one succeeds. Exactly one gets a proper EmailTakenError, not a database driver's raw error message leaking into an API response. That's the win a check-then-insert version can't reliably deliver.
The general rule
Whenever "does X already exist" has to stay true between a check and an action, and more than one request could run that logic concurrently, don't check-then-act. Let a database constraint (a UNIQUE index, in this case) be the actual guard, and catch the violation it produces. The check is real, it's just enforced by the database instead of by application code that runs a moment too early or too late to matter.
Login timing: the dummy hash
verifyUser has its own quiet defense, and it's easy to miss on a first read because the line that matters looks like dead code:
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 is computed once, at startup, by hashing a throwaway random secret nobody will ever type as a password. When verifyUser is asked to check a login for an email that doesn't exist, it doesn't just return null immediately, which would be the natural-looking shortcut. It runs argon2.verify against DUMMY_HASH first, throws the result away, and then returns null. Argon2id is deliberately slow (that's the whole point of it), so this line costs real, measurable time, on purpose, matching roughly what a genuine password check against a real user would cost.
Without it, an attacker watching response times could tell "unknown email" apart from "known email, wrong password" just by how fast the server answers, a real account skips straight to a slow argon2.verify call, an unknown one used to return instantly. That timing gap is an oracle: try a list of a million common emails, time each login attempt, and the slow responses are the registered accounts, no password guessing required, no error message revealing anything, just latency. The dummy hash closes that gap by making both paths cost the same.
The test suite checks this exact property, not just that login works:
it("spends hashing work even when the account does not exist, so timing cannot enumerate users", async () => {
const spy = vi.spyOn(argon2, "verify");
expect(await verifyUser(db, "nobody@example.com", "correct horse battery")).toBeNull();
expect(spy).toHaveBeenCalledTimes(1);
spy.mockRestore();
});argon2.verify gets called exactly once, even for an email that was never registered. That's the assertion that proves the dummy-hash path actually runs, not just that it's written in the source.
Quick check
Why does verifyUser call argon2.verify against a dummy hash when the email lookup finds no matching user?
Registration and login are solid now: no race on duplicate emails, no timing leak on login. The next lesson leaves auth behind and covers the query parameters every list endpoint needs: pagination, filtering and sorting. For the wider set of decisions like this one, how apps actually get broken is where this series' sibling on security picks up.

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…


