perf: stop hashing API secrets as if they were human passwords - #102
Merged
Merged
Conversation
Every request authenticated by client ran bcrypt at cost 12. Measured in
production, not on a laptop:
health-check, no auth 2ms
authenticated request 159ms
bcrypt.checkpw alone 151ms
10 authenticated in parallel 1.59s
health-check *during* that load 315ms
The last line is the one that matters. bcrypt is synchronous and the dependency
calling it is `async def`, so the event loop is blocked for 150ms at a time and
each instance runs one uvicorn worker. An endpoint with no authentication at all
degraded 150x because authentication monopolised the loop.
And the slowness was buying nothing. bcrypt's cost exists to make brute force
against low-entropy human passwords expensive. What is being verified here is a
system-generated random token - 120 bits. No attacker reaches that in 2^120
attempts at 150ms each, nor at a nanosecond each. The cost was paid on every
call from the webapp and defended against nothing.
Client secrets are now HMAC-SHA256 with a server-side pepper, compared with
`compare_digest`, stored with the algorithm in the value:
hmac-sha256$<hex> new
$2b$12$... bcrypt, still accepted
The pepper is what makes this stronger than what it replaces, in the scenario
this repository has actually lived through: if the database leaks - and the
Postgres password was public for eighteen months - bcrypt lets an attacker test
candidates offline, while HMAC gives them nothing to test against, because the
pepper is in the environment and not in the table. Plain SHA-256 would not have
that property.
Three of the eight clients belong to external people whose secrets cannot be
rotated by us, so a successful bcrypt verification rewrites the stored hash in
the new format. Every client migrates on its first request, with no secret
change and nothing to coordinate. A failed upgrade is logged and does not fail
the request.
authenticated request 4ms (was 159ms)
health-check during 10 parallel 32ms (was 315ms)
integration suite 35s (was 109s)
Two things found alongside, both of which mattered more than the latency:
The wrong secret was logged in plaintext - `logging.warn(f"incorrect
api_secret {salted_api_secret}")` - and the redaction filter did not catch it.
During a rotation, a client still holding the old secret writes it to the log
on every request. The api_key of an unknown client was logged too. Both are
gone, and there are tests asserting neither reappears.
`ClientService.fetch` was memoised with `lru_cache`, per process, with two
instances. `cache_clear()` only cleared the instance that served the change, so
rotating a secret left the other instance accepting the old one, and disabling
a client left it enabled there. The cache is gone: verification no longer costs
150ms, so the only remaining cost is a lookup under a millisecond.
The suite got faster, which broke the DOI tests - and that is a real finding
rather than flakiness. The WireMock stub generated the DOI as a timestamp to
the second, so any two creations inside the same second collided on the unique
constraint. The fixture was always wrong; 150ms per request had been spacing
the calls apart by accident. It now appends a random suffix.
AUTH_CLIENT_SECRET_PEPPER is required, with a minimum length, so a missing or
trivial pepper stops the application rather than silently weakening every hash.
It is already set in production.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQda9NZvkbStEeNU54Tqgh
bcrypt raises `ValueError: Invalid salt` on a value that is not a bcrypt hash, and `authorize_client` did not catch it, so it reached the client as a 500 rather than a 401. A truncated one is worse: it panics inside bcrypt's Rust extension as a PanicException, which inherits from BaseException and so escapes `except Exception` entirely. The fix is therefore not to catch it but to not call bcrypt with anything that is not shaped like a bcrypt hash. This matters during the rolling deploy of the format change, where one instance writes a hash the other cannot read for a few seconds, and it matters for whatever the next format transition turns out to be. Also drops the narrative comments I had left in the code. The reasoning belongs here, not next to the lines. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EQda9NZvkbStEeNU54Tqgh
cnmaia
force-pushed
the
perf/client-auth-hmac
branch
from
September 26, 2026 21:04
32813aa to
ac023c9
Compare
CI builds local.env from the template, and `CHANGE_ME` is nine characters against the sixteen the new field requires, so Config validation failed and every module importing it failed to collect. It passed locally only because my own local.env has a real value. The placeholder now says how to generate one and is long enough to load. A placeholder that loads is the right trade here: shipping it to production is what the deploy guard already catches, since the value would then be identical to a tracked one. Reproduced the CI condition before and after, rather than assuming: with `cp local.env.template local.env`, 301 pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EQda9NZvkbStEeNU54Tqgh
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
You asked why an API key check takes 180ms when it should be instant. It shouldn't take 180ms, and the reason it did is that the wrong tool was being used for the job.
Measured in production, not on a laptop
/health-check/, no authbcrypt.checkpwalone/health-check/during that loadThe last row is the one that matters. bcrypt is synchronous and the dependency calling it is
async def, so the event loop is blocked for 150ms at a time, and each instance runs a single uvicorn worker. An endpoint with no authentication at all degraded 150× because authentication monopolised the loop. This was never only an auth problem.Why the slowness bought nothing
bcrypt is a password hash. Its cost exists to make brute force against low-entropy human passwords expensive — "datamap2024".
What is verified here is not a human password. It is
g*aZkbWom3deiAX-vtoT: a system-generated random token, ~120 bits. Nobody reaches that in 2^120 attempts at 150ms each, and nobody reaches it at a nanosecond each either. The cost was charged on nearly every call from the webapp and defended against nothing.What it is now
HMAC-SHA256 with a server-side pepper, compared with
compare_digest, algorithm named in the stored value:The pepper is why this is stronger than what it replaces, in a scenario this repository has actually lived through. If the database leaks — and the Postgres password was public for eighteen months — bcrypt lets an attacker test candidate secrets offline. HMAC gives them nothing to test against, because the pepper lives in the environment, not in the table. Plain SHA-256 would not have that property.
Three of the eight clients belong to external people whose secrets we cannot rotate. So a successful bcrypt verification rewrites the stored hash in the new format: every client migrates on its first request, with no secret change and nothing to coordinate with anyone. A failed upgrade is logged and does not fail the request.
Two things found alongside that matter more than the latency
The wrong secret was logged in plaintext.
logging.warn(f"incorrect api_secret {salted_api_secret}"), and the redaction filter did not catch it. During the rotation now in progress, any client still holding the old secret would write it to the log on every request. The api_key of an unknown client was logged too. Both gone, with tests asserting neither reappears.A per-process cache made
disablea lie.ClientService.fetchwas memoised withlru_cache;cache_clear()only cleared the instance that served the change. With two instances, rotating a secret left the other one still accepting the old secret, and disabling a client left it enabled there until restart. The cache is gone — verification no longer costs 150ms, so all that remains is a sub-millisecond lookup.The suite got faster, which broke the DOI tests
Not flakiness. The WireMock stub generated the DOI as
{{now format='yyyyMMddHHmmss'}}— a timestamp to the second — so any two DOI creations inside the same second collided on the unique constraint. The fixture was always wrong; 150ms of bcrypt per request had been spacing the calls more than a second apart by accident. It now appends a random suffix.Verification
hmac-sha256$.AUTH_CLIENT_SECRET_PEPPERis required with a minimum length, so a missing or trivial pepper stops the application instead of silently weakening every hash. It is already set in production (48 chars), and I confirmedmakereads it intact — the#truncation trap from feat: set and compare credentials without anyone reading them #100 applies to this file.🤖 Generated with Claude Code
https://claude.ai/code/session_01EQda9NZvkbStEeNU54Tqgh