Repository navigation
ADR 0005: reconcile API key hashing (HMAC + pepper) with shipped products (plain SHA-256) #9
Description
Summary
Decision record 0005 (docs/decisions/0005-credential-threat-model.md) says API keys use a "public lookup ID + ≥256 bits of secret", are stored as "HMAC-SHA-256 with a separately configured pepper", and are compared in constant time. examples/reference-product and templates/saas-product implement this (apps/api/src/auth/api-keys.ts, with API_KEY_PEPPER required at startup).
None of the shipped products follow it:
| Product | API key storage | Lookup |
|---|---|---|
| SendLit | sl_live_ + 32 random bytes, plain SHA-256 hex (apps/api/src/apikey/secret.ts) |
Equality on the unique key_hash |
| FrontLit | fl_live_ + 24 random bytes, plain SHA-256 hex |
Equality on the unique key_hash |
| MediaLit | Moving from plaintext to plain SHA-256, planned in docs/prds/apikey.md |
Equality on the unique key_hash |
The org already uses HMAC where it matters: webhook signing and verification, and SendLit's suppression-list fingerprint. In that case the input is a guessable email address, and the key is versioned (SUPPRESSION_HASH_KEY, _V2). For high-entropy random API keys, a pepper protects only against an attacker who has the database but not the environment. Even then, 256-bit secrets can't be brute-forced from their hashes.
Why MediaLit chose SHA-256
- Consistency with SendLit and FrontLit.
- Bundled self-hosting. SendLit, FrontLit and CourseLit ship MediaLit inside their own
docker-compose.yml. A pepper adds one more permanent secret to every product's.env, and losing or changing it silently invalidates every key. Without one, a bundled install needs just a single sharedMEDIALIT_APIKEY=ml_live_$(openssl rand -hex 32). MediaLit seeds that key at startup throughMEDIALIT_BOOTSTRAP_API_KEY.
Decision needed
Pick one:
- Amend decision record 0005. Allow plain SHA-256 when the secret is CSPRNG-generated with at least 256 bits, plus a product prefix (
<product>_live_). Keep HMAC with a pepper for low-entropy or user-chosen secrets. Update the template, or document both options. - Keep decision record 0005 as is and migrate SendLit, FrontLit and MediaLit together. Ideally they'd use a shared helper in
@codelitdev/platform, so the format, digest, constant-time compare and pepper versioning are written once. That needs:- a pepper version stored per key row, for rotation;
- a plan for re-issuing existing keys, because SHA-256 digests can't be converted to HMAC digests;
- a story for bundled compose files, such as generating the pepper in a setup script.
Whichever we choose, two related controls in decision record 0005 are currently broken in products:
- "Never log raw keys": SendLit logs its bootstrap organization key, and MediaLit logs its first admin key. MediaLit's plan replaces that with an operator-supplied
MEDIALIT_BOOTSTRAP_API_KEY. - "Public lookup ID": FrontLit's keys have no public ID and expose the internal UUID (codelitdev/frontlit#68).
Related: codelitdev/sendlit#59, codelitdev/frontlit#68.
Activity