Bug
File: apps/api/src/middleware/auth.ts + apps/web/src/lib/actions/settings.ts
Problem:
API keys are generated with an ayg_ prefix:
// apps/web/src/lib/actions/settings.ts:308
const plaintext = `ayg_${randomBytes(24).toString("hex")}`;
const keyHash = createHash("sha256").update(plaintext).digest("hex");
The hash is stored including the prefix (sha256("ayg_<48-hex-chars")).
On the API side, the incoming key is hashed as-is:
// apps/api/src/middleware/auth.ts:66
const keyHash = await sha256Hex(presented);
This means:
- The
ayg_ prefix is part of the key material being hashed on both sides consistently — so it does not break auth.
- However, the
ayg_ prefix is a constant prefix in every key, which slightly reduces the entropy of the key: an attacker who observes a hash only needs to brute-force randomBytes(24).toString("hex") (192 bits), not the full presented string. This is academically fine (192 bits >> required), but the prefix is included unnecessarily.
- More importantly: the hash does not include a per-key salt or HMAC, meaning a SHA-256 collision on the 48-char hex random portion is the only barrier. This is fine for current threat models but is worth noting for future rotation to Argon2 or HMAC-SHA256.
Separate finding — no timingSafeEqual:
The hash comparison is done entirely in PostgreSQL (eq(apiKeys.keyHash, keyHash)) rather than in JavaScript. This is database-side comparison and is not vulnerable to JS timing attacks. However, if the comparison is ever moved to application code (e.g. for caching purposes), the absence of timingSafeEqual would become a timing oracle. This pattern should be documented as load-bearing.
Expected behaviour:
Strip the ayg_ prefix before hashing, or document that the prefix is intentionally part of the key material (for format validation). Add a comment near sha256Hex(presented) noting the comparison is DB-side and therefore timing-safe.
Fix:
// Strip well-known prefix before hashing (format validation + cleaner key material)
const rawKey = presented.startsWith('ayg_') ? presented.slice(4) : presented;
const keyHash = await sha256Hex(rawKey);
And correspondingly in settings.ts:
const keyHash = createHash("sha256").update(plaintext.slice(4)).digest("hex");
Severity: MEDIUM — the prefix is included in the hash on both sides consistently so auth is not broken, but the inconsistency between "prefix is key material" and "prefix is a format hint" creates a subtle landmine for future developers who may strip the prefix on one side only.
Bug
File:
apps/api/src/middleware/auth.ts+apps/web/src/lib/actions/settings.tsProblem:
API keys are generated with an
ayg_prefix:The hash is stored including the prefix (
sha256("ayg_<48-hex-chars")).On the API side, the incoming key is hashed as-is:
This means:
ayg_prefix is part of the key material being hashed on both sides consistently — so it does not break auth.ayg_prefix is a constant prefix in every key, which slightly reduces the entropy of the key: an attacker who observes a hash only needs to brute-forcerandomBytes(24).toString("hex")(192 bits), not the full presented string. This is academically fine (192 bits >> required), but the prefix is included unnecessarily.Separate finding — no
timingSafeEqual:The hash comparison is done entirely in PostgreSQL (
eq(apiKeys.keyHash, keyHash)) rather than in JavaScript. This is database-side comparison and is not vulnerable to JS timing attacks. However, if the comparison is ever moved to application code (e.g. for caching purposes), the absence oftimingSafeEqualwould become a timing oracle. This pattern should be documented as load-bearing.Expected behaviour:
Strip the
ayg_prefix before hashing, or document that the prefix is intentionally part of the key material (for format validation). Add a comment nearsha256Hex(presented)noting the comparison is DB-side and therefore timing-safe.Fix:
And correspondingly in
settings.ts:Severity: MEDIUM — the prefix is included in the hash on both sides consistently so auth is not broken, but the inconsistency between "prefix is key material" and "prefix is a format hint" creates a subtle landmine for future developers who may strip the prefix on one side only.