Skip to content

ADR 0005: reconcile API key hashing (HMAC + pepper) with shipped products (plain SHA-256) #9

Open

Description

@clbotdev

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 shared MEDIALIT_APIKEY=ml_live_$(openssl rand -hex 32). MediaLit seeds that key at startup through MEDIALIT_BOOTSTRAP_API_KEY.

Decision needed

Pick one:

  1. 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.
  2. 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    documentationImprovements or additions to documentation

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions