Derive keys

Turn one root secret into a separate key for each purpose, so a key leaked from one feature exposes none of the others.

Think of HKDF (HMAC-based key derivation function, RFC 5869) as a key-cutting machine. You give it the root secret (ikm, input keying material) and a label, and it cuts a key for that label. Same secret and label, same key; a new label gives an unrelated one. You keep one secret in the environment and derive the rest at start-up. HKDF is fast, so it only suits secrets that are already random, and Concepts explains why a password needs Argon2 instead.

#Before you start

  • Node.js 22.13 or later (24 or later on the 24 line), or any runtime with the Web Crypto API.
  • The root secret must be random. Generate it once with secureGenerate called with { length: 43, specials: false }, which gives 256 random bits as letters and digits, or use an ECDH shared secret. A password gives keys an attacker can guess, so use Argon2 for that.
  • Keep the root secret and every label fixed. Change either and the derived key changes with it, so you can no longer read data encrypted under the old key.
  • HKDF accepts an empty root secret. RFC 5869 allows it, and the Noise protocol relies on it with a secret salt. With a public salt, as below, an empty root secret derives a key anyone can compute. Stop at start-up when the variable is missing, and never fall back to "".

#Derive one key per purpose

Here one root secret turns into two unrelated keys. One signs cookies and the other encrypts files:

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

const rootSecret = process.env.APP_ROOT_SECRET;
if (!rootSecret) throw new Error("APP_ROOT_SECRET is not set");

// Imported once. Every derivation below reuses it.
const root = await importHkdfKey(rootSecret);

/** 32 bytes for one purpose. A different label gives an unrelated key. */
export function derive(purpose: string): Promise<Uint8Array<ArrayBuffer>> {
  return hkdf(root, { salt: "acme-app", info: `acme/${purpose}/v1` });
}

/** Signs session cookies. */
export const cookieKey: CryptoKey = await importHmacKey(await derive("session-cookie"));

/** Encrypts uploaded files with AES-GCM. */
export const fileKey: CryptoKey = await crypto.subtle.importKey(
  "raw",
  await derive("file-encryption"),
  "AES-GCM",
  false,
  ["encrypt", "decrypt"],
);
  • Label: info says which app, which purpose and which version. To rotate one key, bump its label to v2; every other key stays put.
  • Salt: a fixed value for your application. It doesn't need to be secret.
  • Import once: importHkdfKey gives you a key that can only derive, never sign or encrypt, and every hkdf call reuses it.
  • Bytes out: with a key or bytes as ikm, hkdf returns a Uint8Array that importHmacKey and crypto.subtle.importKey take as it is. A string ikm gets you hex text unless you set returnAs: "bytes".

#When it fails

The first three rows are mistakes HKDF cannot see, and the rest throw:

SituationWhat happens
The root secret is a passwordNo error. The derived keys are as guessable as the password.
The root secret is emptyNo error. With a public salt, the result is a public key. Check the variable at start-up.
Two purposes share one labelNo error. Both get the same key. Give each purpose its own info.
length is outside 1 to 255 times the hash size (8160 bytes for SHA-256)Throws OUT_OF_RANGE. Derive several keys with distinct labels for more.
The key passed as ikm is not from importHkdfKey, such as an HMAC keyThrows OUT_OF_RANGE, naming what the key is.
ikm, salt or info is not text or bytesThrows INVALID_TYPE.
algorithm or returnAs is a name the library does not knowThrows UNSUPPORTED.
The runtime refuses the import or the derivationThrows PLATFORM, with the runtime's error as cause.

Errors lists every code and how to handle it.

#Options

All five options are optional for hkdf(ikm, options):

OptionDefaultNotes
infoemptyThe label that separates one key from another.
saltemptyA public value. RFC 5869 treats an empty salt as a hash-length string of zero bytes.
length32Output bytes. 32 fits AES-256 and HMAC-SHA-256.
algorithm"SHA-256"Set per call, also with a key, because an HKDF key carries no hash.
returnAsmirrors ikmHex for a string, a Uint8Array for bytes or a key.

#Going further

Sometimes the root secret comes from a key exchange or a password instead of the environment:

  • Two keys from one exchange: after an ECDH exchange, ask for length: 64 and split it with subarray(0, 32) and subarray(32), one key for each direction.
  • Keys from a password: let Argon2 do the slow part first with argon2(password, salt, { returnAs: "bytes" }), then derive labeled keys from what it returns.
  • Encryption: give the derived bytes to crypto.subtle, the way the AES-GCM key above does. What this library is not explains why.
  • A complete setup: the key derivation recipe puts it all together.