Key derivation
HKDF turns one high-entropy secret into as many independent keys as you need, each labelled with what it is for.
Note
ELI5. You have one secret, and you need several keys made from it: one to encrypt with, one to authenticate with, one to build identifiers from. Reusing the same secret for all three is a bad idea, because breaking one use breaks the others. HKDF is the machine that takes your one secret and stamps out separate keys, each tied to a label you choose. Different label, different key, and knowing one tells an attacker nothing about the rest. Do not feed it a password: HKDF is fast, and a password needs something slow. That is Argon2.
import { hkdf } from "unsecure/hkdf";
const encKey = await hkdf(sharedSecret, { salt, info: "myapp/enc/v1", returnAs: "bytes" });
const macKey = await hkdf(sharedSecret, { salt, info: "myapp/mac/v1", returnAs: "bytes" });#Signature
function importHkdfKey(ikm: string | BytesSource): Promise<CryptoKey>;
function hkdf(
ikm: string | BytesSource | CryptoKey,
options?: {
algorithm?: "SHA-1" | "SHA-256" | "SHA-384" | "SHA-512";
length?: number;
salt?: string | BytesSource;
info?: string | BytesSource;
returnAs?: "hex" | "base64" | "b64" | "base64url" | "b64url" | "uint8array" | "bytes";
},
): Promise<string | Uint8Array>;ikm is the input keying material: a shared secret, ECDH output, a seed. It must already be high-entropy, because HKDF adds no work factor of its own.
#Options
| Option | Type | Default | What it does |
|---|---|---|---|
algorithm | DigestAlgorithm | "SHA-256" | The digest under the extract and expand steps. Matched case-insensitively. |
length | number | 32 | Output bytes. An integer from 1 to 255 * HashLen (8160 for SHA-256). |
salt | string | BytesSource | empty | A non-secret, ideally random value. RFC 5869 §2.2 allows omitting it. |
info | string | BytesSource | empty | The context label. This is what separates one derived key from another. |
returnAs | DigestReturnAs | mirrors the ikm type | The shape of the derived material. |
#Return shape
Like the other primitives, the default mirrors the input: a string IKM gives a hex string, a BytesSource or CryptoKey IKM gives a Uint8Array.
await hkdf("shared-secret-string", { salt: "a-pinch-of-salt", info: "myapp/enc/v1" });
// "38d13601942e50724311a0f54795b2f6ea7d6d3ff467f029daaea195e26d4fe8"
await hkdf("shared-secret-string", {
salt: "a-pinch-of-salt",
info: "myapp/mac/v1",
length: 16,
returnAs: "base64url",
});
// "j4euWmFvuRQLFCrkM_8R_Q"
await hkdf("shared-secret-string", { info: "ctx", returnAs: "bytes" });
// Uint8Array(32) [160, 254, 3, 241, …]Tip
Key material is usually consumed as bytes. When the IKM is a string and the result is going straight into AES or HMAC, say returnAs: "uint8array" — otherwise you get hex text and will encode it again by accident. Pick "base64url" only when the material is about to travel or be stored as text.
#Domain separation is the whole point
One IKM, many keys, one info each. Two derivations with the same IKM, salt and info produce the same key; change the info and the keys are cryptographically independent.
const encKey = await hkdf(ikm, { salt, info: "myapp/enc/v1", returnAs: "bytes" });
const macKey = await hkdf(ikm, { salt, info: "myapp/mac/v1", returnAs: "bytes" });
const idKey = await hkdf(ikm, { salt, info: "myapp/id/v1", returnAs: "bytes" });Give every info a version suffix. When you need to rotate a key, bump v1 to v2 and old data keeps deriving with the old label.
// Ambiguous: which key is this?
await hkdf(ikm, { salt, info: "key" });
// Specific, and rotatable
await hkdf(ikm, { salt, info: "myapp/session-cookie/v1" });#Reusing an imported IKM
importHkdfKey() imports the IKM once and returns a non-extractable, deriveBits-only CryptoKey. This is the natural shape for HKDF, since one IKM usually feeds many derivations.
import { hkdf, importHkdfKey } from "unsecure/hkdf";
const key = await importHkdfKey(sharedSecret);
const encKey = await hkdf(key, { salt, info: "myapp/enc/v1" });
const macKey = await hkdf(key, { salt, info: "myapp/mac/v1" });Unlike an HMAC key, an HKDF key carries no hash, so algorithm still belongs on each call. A key that is not an HKDF key with the deriveBits usage is OUT_OF_RANGE, and the message names what it actually is:
await hkdf(await importHmacKey("s"), { info: "x" });
// UnsecureError OUT_OF_RANGE: hkdf: key must be an HKDF key with the "deriveBits" usage, got HMAC with [sign].#Errors
| Cause | code |
|---|---|
ikm, salt or info is neither text nor bytes | INVALID_TYPE |
length outside 1 … 255 * HashLen | OUT_OF_RANGE |
A CryptoKey that is not an HKDF deriveBits key | OUT_OF_RANGE |
An algorithm or returnAs name the library does not know | UNSUPPORTED |
| Web Crypto refused the derivation | PLATFORM |
await hkdf("x", { length: 0 });
// UnsecureError OUT_OF_RANGE: hkdf: length must be an integer between 1 and 8160, got 0.
await hkdf("x", { length: 9000 });
// UnsecureError OUT_OF_RANGE: hkdf: length must be an integer between 1 and 8160, got 9000.
await hkdf("x", { algorithm: "sha3" });
// UnsecureError UNSUPPORTED: hkdf: unsupported algorithm "sha3"; expected one of SHA-1, SHA-256, SHA-384, SHA-512.The 255 * HashLen ceiling is RFC 5869's, and it is checked here rather than surfaced as an opaque OperationError from the runtime. If you need more material than one derivation allows, derive several keys with distinct info values.
#Recipe: split one exchange into a pair of directional keys
import { hkdf } from "unsecure/hkdf";
async function deriveSessionKeys(sharedSecret: Uint8Array, sessionId: string) {
// 64 bytes: two 256-bit keys back to back
const okm = await hkdf(sharedSecret, {
salt: sessionId,
info: "myapp/session-keys/v1",
length: 64,
});
return {
clientToServer: okm.slice(0, 32),
serverToClient: okm.slice(32, 64),
};
}#When to use Argon2 instead
HKDF has no work factor. It is one HMAC-based extract followed by a few more, and it is meant to be cheap.
| The input is… | Use |
|---|---|
| A shared secret, ECDH output, a random seed, a generated API key | hkdf |
| A password, a passphrase, a PIN, a recovery phrase a human invented | argon2 |
Running HKDF on a password is no stronger than running one HMAC on it, and an attacker with a GPU tries billions of guesses per second.
// Wrong: a password has nowhere near enough entropy for this
const key = await hkdf(userPassword, { salt, info: "account" });
// Right: pay for the guessing defence first
const key = await argon2(userPassword, salt, { length: 32, returnAs: "bytes" });You can use both. Argon2 turns the password into high-entropy material, and HKDF splits that material into labelled sub-keys.