Back to Feed
article 3m read

Bcrypt came off the API keys, then a day went to finding the paths I missed

Wrong API keys made my auth service run a slow bcrypt check per candidate row, which guarded nothing for long random keys. I moved them to SHA-256 with constant-time compares, missed several paths on the first pass, and left a slow legacy branch and an untested format check behind.

Article DepthPractice

Flow

How the switch landed that day

Read from the diffs. I can't tell whether any real request hit the gap.

  1. 1First commit

    Main checks, key helper, tenant creator, seeds and two tests moved to SHA-256.

  2. 2The gap

    Rotation and sign-up still wrote bcrypt while some checks expected digests, and one check still ran bcrypt on digests.

  3. 3Follow-up commits

    Rotation, sign-up, middleware and app routes moved over too.

  4. 4Admin module

    One place makes hashes and reads both formats. Unknown formats are rejected.

  5. 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.

Keep reading

Pick up a connected idea or branch into a nearby one.

5 paths forward