Derive keys from one secret

Keep one random root secret in your secret manager, and derive a separate, labeled key from it for each job: session cookies, CSRF tokens, signed download links.

Say one secret signs both your cookies and your cross-site request forgery (CSRF) tokens. Then a leak through one exposes the other, and changing it breaks both. HKDF (HMAC-based key derivation, RFC 5869) works like a key-cutting machine. Feed it the root secret (ikm) and a label, and it cuts a key for that label alone. The same root secret and label always give the same key, in every process, so there's nothing derived to store. Concepts explains how this differs from hashing.

#Before you start

  • A root secret of 32 random bytes, generated once and kept in your secret manager as APP_ROOT_SECRET. One way to generate it:

    base64Stringify(secureRandomBytes(32), { alphabet: "base64url" });
  • HKDF is not for passwords. A password has little entropy, and HKDF adds no cost per guess. Run a password through Argon2 first.

  • Derive at start-up, as below, rather than on every request. Each derivation is one Web Crypto call.

#Derive one key per purpose

This module cuts three signing keys when it loads and exports them ready to use:

derive-keys.ts
import { hkdf, importHkdfKey } from "unsecure/hkdf";
import { importHmacKey } from "unsecure/hmac";
import { base64Parse } from "unsecure/utils";

// 32 random bytes as base64url, from your secret manager, never the database
const secret = process.env.APP_ROOT_SECRET;
if (!secret) throw new Error("APP_ROOT_SECRET is not set");
const rootBytes = base64Parse(secret, { alphabet: "base64url", returnAs: "bytes" });
// hkdf accepts a short or empty root secret, so check the length yourself
if (rootBytes.length < 32) throw new Error("APP_ROOT_SECRET must hold 32 random bytes");

// Imported once: every derivation below reuses it
const root = await importHkdfKey(rootBytes);

/** 32 bytes for one purpose. Bump the version to rotate that key alone. */
function derive(purpose: string): Promise<Uint8Array<ArrayBuffer>> {
  return hkdf(root, { info: `acme-notes/${purpose}/v1` });
}

/** Signs session cookies. */
export const cookieKey: CryptoKey = await importHmacKey(await derive("session-cookie"));
/** Signs CSRF tokens. A leaked cookie key cannot forge these. */
export const csrfKey: CryptoKey = await importHmacKey(await derive("csrf-token"));
/** Signs expiring download links. */
export const downloadKey: CryptoKey = await importHmacKey(await derive("download-link"));

The checks at the top guard the root secret, and the labels below keep the keys apart:

  • Length check: hkdf accepts an empty root secret, as RFC 5869 allows, so it won't notice one that's missing or cut short. Without the check, a short APP_ROOT_SECRET derives keys an attacker can brute-force.
  • Labels: info is the label, and it's what keeps the keys apart. Name the app, the purpose and a version, like acme-notes/csrf-token/v1, and never give two purposes the same label.
  • Import once: importHkdfKey wraps the root secret in a key that can only derive and can't be read back out. Every hkdf call after it skips the import.
  • Bytes in, keys out: given a CryptoKey, hkdf returns bytes, and importHmacKey takes them as they are. Each exported key signs with hmac and checks with hmacVerify, and none of them can be read back either.

#When it fails

Every row except the last stops the process at start-up, which is exactly where you want a configuration mistake to show up:

SituationWhat happens
APP_ROOT_SECRET unset or emptyThe start-up check throws APP_ROOT_SECRET is not set.
APP_ROOT_SECRET under 32 bytesThe length check throws.
APP_ROOT_SECRET is not base64urlbase64Parse throws MALFORMED. Strict decoding refuses stray characters, a trailing newline included. See Handle errors.
Root secret replacedEvery derived key changes, so existing cookies, CSRF tokens and download links stop verifying. Rotate single keys by label instead.
length above 255 times the hash sizehkdf throws OUT_OF_RANGE. That is 8160 bytes for SHA-256. Derive several keys with different labels instead.

#Going further

Rotation, and the options you'll reach for less often:

  • Rotating one key: bump its label to v2 for new signatures and keep deriving v1 to check old ones until they expire. The root secret never has to move.
  • Per-tenant keys: put the tenant id in the label, such as `acme-notes/export/v1/${tenantId}`, and derive when you need it. You store nothing per tenant.
  • Salt: optional and not secret, and a random root secret doesn't need one. Protocols such as Noise call HKDF with an empty input and a secret salt, which is why hkdf accepts an empty input at all.
  • Other hashes: pass algorithm: "SHA-512" per call. An HKDF key has no hash of its own, so one imported root serves every algorithm. See Derive keys for every option.