
# 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](/crypto/password-hashing).
::

```ts
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

```ts
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`.

```ts
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.

```ts
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.

```ts
// 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.

```ts
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:

```ts
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`     |

```ts
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

```ts
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`](/crypto/password-hashing) |

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.

```ts
// 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.
