Skip to content

[MEDIUM] auth.ts: ayg_ prefix included in API key hash — prefix is load-bearing key material but treated as cosmetic #245

Description

@walidboulanouar

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:

  1. The ayg_ prefix is part of the key material being hashed on both sides consistently — so it does not break auth.
  2. 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.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions