Flow
How the switch landed that day
Read from the diffs. I can't tell whether any real request hit the gap.
1First commit
Main checks, key helper, tenant creator, seeds and two tests moved to SHA-256.
2The gap
Rotation and sign-up still wrote bcrypt while some checks expected digests, and one check still ran bcrypt on digests.
3Follow-up commits
Rotation, sign-up, middleware and app routes moved over too.
4Admin module
One place makes hashes and reads both formats. Unknown formats are rejected.
5Left as is
Old bcrypt rows keep the slow check. The public app reads digests only.
Every wrong API key sent to my auth service made the server run a slow bcrypt check on each possible match before it could say no.
Junk keys cost me, not them
A caller sends a secret key with each request. The server keeps only a hash of each key. With bcrypt, it can't look the key up by that hash, so it grabs a few possible rows and checks them one by one. A real key stops early. A junk key checks every row. Anyone can send junk, no account needed.
I never measured this, and I never saw the service fall over. But the math was bad: a slow hash per row for me, one cheap request for them. My understanding is that the bcrypt library I use runs on the same thread as everyone else's requests.
Slow hashing guards guessable secrets
bcrypt is slow on purpose, because people pick guessable passwords and slowness makes guessing expensive. My keys are made by the server from long random bytes. Nobody can guess them, slow hash or fast. So the slowness cost me CPU and guarded nothing.
One hash, then cheap compares
Keys now store a SHA-256 digest. The incoming key is hashed once, then each row gets a quick, constant-time compare, with a length check first because Node's compare throws when sizes differ.
const probe = sha256Hex(presented)
return rows.find(row => sameLength(probe, row.stored) && constantTimeEq(probe, row.stored)) ?? null
User passwords stay on bcrypt.
The first pass missed paths
My first commit changed the main checks but missed rotation, sign-up and a few other routes. For a while, some places wrote one format while other places checked for the other, so those keys wouldn't match. Later commits that day fixed the rest. Whether a real request ever hit that gap, I haven't checked. I should have searched for every bcrypt call first.
Reading two formats
The admin app got one small module for this. It looks at what's stored:
- a SHA-256 digest gets the fast check
- a bcrypt hash gets the old slow check
- anything else is rejected
In that day's changes, the public app only knows digests. Any old bcrypt key there just stops working, and I can't tell from the diffs whether any existed.
Old rows still cost
The old bcrypt branch is still slow, so junk keys against old rows still cost me. Nothing in these commits rewrites old rows or forces new keys. The tests that changed only check the new digests. Nothing tests the format check, and the report in the repo says the tests weren't run where this was written.
My keys look long and random in the code, but I haven't checked the length of every one, and everything above leans on that.