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
secureGeneratecalled 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:
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:
infosays which app, which purpose and which version. To rotate one key, bump its label tov2; every other key stays put. - Salt: a fixed value for your application. It doesn't need to be secret.
- Import once:
importHkdfKeygives you a key that can only derive, never sign or encrypt, and everyhkdfcall reuses it. - Bytes out: with a key or bytes as
ikm,hkdfreturns aUint8ArraythatimportHmacKeyandcrypto.subtle.importKeytake as it is. A stringikmgets you hex text unless you setreturnAs: "bytes".
#When it fails
The first three rows are mistakes HKDF cannot see, and the rest throw:
| Situation | What happens |
|---|---|
| The root secret is a password | No error. The derived keys are as guessable as the password. |
| The root secret is empty | No error. With a public salt, the result is a public key. Check the variable at start-up. |
| Two purposes share one label | No 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 key | Throws OUT_OF_RANGE, naming what the key is. |
ikm, salt or info is not text or bytes | Throws INVALID_TYPE. |
algorithm or returnAs is a name the library does not know | Throws UNSUPPORTED. |
| The runtime refuses the import or the derivation | Throws 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):
| Option | Default | Notes |
|---|---|---|
info | empty | The label that separates one key from another. |
salt | empty | A public value. RFC 5869 treats an empty salt as a hash-length string of zero bytes. |
length | 32 | Output 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. |
returnAs | mirrors ikm | Hex 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: 64and split it withsubarray(0, 32)andsubarray(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.