All Docs
FeaturesCalmony PayUpdated March 15, 2026

Security Advisory: API Key Hashing (SEC-24)

Security Advisory: API Key Hashing — SEC-24

Severity: Medium
Control: SEC-24
Category: Data Protection
Affected file: src/lib/pay/auth.ts
Status: Open — remediation pending


Summary

Calmony Pay API keys (sk_live_… and sk_test_…) are hashed with SHA-256 before being stored. While the entropy of the keys themselves is high (32 random bytes = 64 hex characters), SHA-256 is a fast general-purpose hash function. Should the hash database ever be leaked, an attacker with GPU hardware could mount an offline brute-force attack at significantly higher throughput than would be possible against a slow KDF.

Additionally, key rotation — generating a new key to replace an existing one as a single atomic operation — is not currently implemented. Only revocation is available.


Background

How API keys are currently stored

When a new API key is issued, the raw key is shown to the user once and never stored in plaintext. Instead, a SHA-256 digest of the key is persisted to the database and used for subsequent lookup:

// src/lib/pay/auth.ts (current behaviour)
import { createHash } from 'crypto';

const keyHash = createHash('sha256').update(rawKey).digest('hex');
// keyHash is stored; rawKey is discarded

On every authenticated request, the presented key is hashed with the same algorithm and compared against stored digests.

Why SHA-256 alone is a concern

  • Speed is the problem. SHA-256 is designed to be fast. A modern GPU can compute billions of SHA-256 digests per second, making offline dictionary or brute-force attacks practical if hashes are leaked.
  • OWASP guidance. OWASP recommends a slow KDF (bcrypt, Argon2, PBKDF2) for any stored secret that could be attacked offline. The high entropy of the key values mitigates — but does not eliminate — this risk.
  • No rotation. Without a rotate endpoint, teams must revoke a key and issue a completely new one manually, increasing the window of disruption during a suspected compromise.

Risk Assessment

FactorDetail
Key entropyHigh — 32 random bytes (64 hex chars). Brute-force is expensive even with SHA-256.
Attack vectorRequires database compromise (hash leak) before offline attack is possible.
Attack feasibilityFeasible with GPU acceleration if hashes are leaked and attacker has partial key knowledge.
Missing controlNo atomic key rotation endpoint.

Recommended Remediation

Option 1 — HMAC-SHA256 with a server-side secret (preferred)

Replace bare SHA-256 with an HMAC keyed on a server-side secret stored in an environment variable. This means an attacker who obtains only the hash database cannot mount an offline attack without also compromising the server secret.

// Proposed implementation
import { createHmac } from 'crypto';

const hmacSecret = process.env.API_KEY_HMAC_SECRET; // 32+ random bytes, stored securely

function hashApiKey(rawKey: string): string {
  return createHmac('sha256', hmacSecret)
    .update(rawKey)
    .digest('hex');
}

Trade-offs:

  • Lookup remains O(1) and fast — no per-request KDF cost.
  • Offline brute-force is infeasible without knowledge of API_KEY_HMAC_SECRET.
  • Requires secure storage and rotation of the HMAC secret itself.
  • Requires a one-time migration to re-hash all existing stored keys.

Option 2 — Slow KDF (bcrypt / Argon2 / PBKDF2)

Use a slow KDF as recommended by OWASP. This adds per-verification latency proportional to the cost factor.

// Example with bcrypt
import bcrypt from 'bcrypt';

// On key creation
const keyHash = await bcrypt.hash(rawKey, 12);

// On key verification
const isValid = await bcrypt.compare(presentedKey, storedHash);

Trade-offs:

  • Strong offline attack resistance regardless of key entropy.
  • Adds meaningful per-request latency (bcrypt at cost 12 ≈ 200–400 ms).
  • Cannot use a simple equality lookup — must iterate candidate keys for a given account.

Add a Key Rotation Endpoint

A rotate admin procedure should atomically create a new key and revoke the old one:

// Proposed rotate endpoint sketch
rotateProcedure: adminProcedure
  .input(z.object({ keyId: z.string() }))
  .mutation(async ({ input, ctx }) => {
    const newRawKey = generateApiKey();          // generate new sk_live_ / sk_test_
    const newHash   = hashApiKey(newRawKey);     // hash with chosen algorithm

    await ctx.db.$transaction([
      ctx.db.apiKey.update({                     // revoke old key
        where:  { id: input.keyId },
        data:   { revokedAt: new Date() },
      }),
      ctx.db.apiKey.create({                     // store new key
        data: { hash: newHash, /* … */ },
      }),
    ]);

    return { key: newRawKey };                   // returned once; never stored
  }),

The old and new key operations must be wrapped in a single database transaction to prevent a gap where neither key is valid.


Environment Variable Required

If the HMAC-SHA256 approach is adopted, the following environment variable must be provisioned:

VariableDescription
API_KEY_HMAC_SECRETA cryptographically random secret (minimum 32 bytes, base64 or hex encoded) used to HMAC API key values before storage. Store in a secrets manager; do not commit to source control.

References