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:
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:
hkdfaccepts an empty root secret, as RFC 5869 allows, so it won't notice one that's missing or cut short. Without the check, a shortAPP_ROOT_SECRETderives keys an attacker can brute-force. - Labels:
infois the label, and it's what keeps the keys apart. Name the app, the purpose and a version, likeacme-notes/csrf-token/v1, and never give two purposes the same label. - Import once:
importHkdfKeywraps the root secret in a key that can only derive and can't be read back out. Everyhkdfcall after it skips the import. - Bytes in, keys out: given a
CryptoKey,hkdfreturns bytes, andimportHmacKeytakes them as they are. Each exported key signs withhmacand checks withhmacVerify, 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:
| Situation | What happens |
|---|---|
APP_ROOT_SECRET unset or empty | The start-up check throws APP_ROOT_SECRET is not set. |
APP_ROOT_SECRET under 32 bytes | The length check throws. |
APP_ROOT_SECRET is not base64url | base64Parse throws MALFORMED. Strict decoding refuses stray characters, a trailing newline included. See Handle errors. |
| Root secret replaced | Every 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 size | hkdf 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
v2for new signatures and keep derivingv1to 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
hkdfaccepts 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.