Home › Security › Crypto primitives
algorithm low

HKDF key derivation (extract-and-expand)

Problem A system holds one shared secret, such as a key-agreement result or a master key, but needs many independent keys from it (per direction, purpose, file, device or epoch), and the secret itself is not uniformly random.

HMAC-based two-step key derivation: extract a uniform pseudorandom key from any secret, then expand it into as many context-bound keys as you need.

extract 1 HMAC; expand ceil(L/HashLen) sequential HMACs; L at most 255*HashLen (8160 B with SHA-256)
kdfhmackey-separationextract-and-expandrfc-5869
Overview

A Diffie-Hellman result, a hardware master key or a pre-shared secret is one value, but a system needs many keys: one per direction, per purpose, per file, per device, per epoch. HKDF first extracts a uniform pseudorandom key (PRK) from the raw secret, then expands it with a label (info) into any number of keys that are independent of each other. The label is the whole trick: same secret, different label, unrelated key.

Key facts

Use when

  • Turning a key-agreement output into traffic keys: web, VPN and messaging channels (TLS, Noise, WireGuard, Double Ratchet).
  • Per-object, per-tenant or per-device keys from one master: encrypted storage, IoT fleets, HSM-backed services.
  • Splitting one session secret into encryption, MAC and IV keys or per-epoch keys: game servers, payment terminals, health-record stores.

Avoid when

  • The input is a password or other low-entropy secret: use Argon2.
  • You need a fresh key per message with forward secrecy: ratchet, do not just vary info (Double Ratchet).
  • You need a MAC or a hash: use HMAC directly.

Deep dive

How it works

In: IKM (secret), optional salt, info label, length L · Out: L bytes of output keying material (OKM) · State: none (PRK may be cached) · Cost: 1 + ceil(L/32) HMAC-SHA-256 calls.

  1. Extract: PRK = HMAC-Hash(salt, IKM); the salt is the HMAC key. No salt means HashLen zero bytes (RFC 5869 section 2.2). This concentrates the entropy of a non-uniform secret, such as an elliptic-curve point, into a uniform 32-byte key.
  2. Choose info: protocol, version, role or direction, purpose, algorithm, for handshakes the transcript hash. It must not depend on the IKM (section 3.2).
  3. Expand: N = ceil(L/HashLen); reject L > 255*HashLen (the counter is one byte).
  4. T(0) is empty; T(i) = HMAC(PRK, T(i-1) || info || byte(i)).
  5. OKM = first L bytes of T(1) || ... || T(N).
  6. Slice OKM into keys, or expand again with a different info per key. Never reuse one info for two purposes.

Skipping extract is allowed only when the IKM is already a uniform key of at least HashLen bytes (from a CSPRNG); a Diffie-Hellman value must go through extract (section 3.3). Krawczyk's CRYPTO 2010 paper gives the formal analysis. TLS 1.3 wraps expand in HKDF-Expand-Label: info is a struct of 2-byte length, "tls13 " + label and a context, usually the transcript hash (RFC 8446 section 7.1). Noise's HKDF() is HKDF with the chaining key as salt and empty info, giving two or three 32-byte outputs (Noise specification section 4.3).

Code

Rust, std only; SHA-256 constants derived from the primes. Asserts RFC 5869 Appendix A cases 1-3 (PRK and OKM); cases 4-7 use SHA-1.

use std::sync::OnceLock;

// SHA-256 (FIPS 180-4); constants derived from the first 64 primes
fn consts() -> &'static ([u32; 8], [u32; 64]) {
    static C: OnceLock<([u32; 8], [u32; 64])> = OnceLock::new();
    C.get_or_init(|| {
        let f = |x: f64| ((x - x.floor()) * 4294967296.0) as u32;
        let p: Vec<u64> = (2u64..).filter(|n| (2..*n).take_while(|d| d * d <= *n).all(|d| n % d != 0)).take(64).collect();
        let (mut h, mut k) = ([0u32; 8], [0u32; 64]);
        for i in 0..8 { h[i] = f((p[i] as f64).sqrt()); }
        for i in 0..64 { k[i] = f((p[i] as f64).cbrt()); }
        (h, k)
    })
}
pub fn sha256(msg: &[u8]) -> [u8; 32] {
    let (h0, k) = consts();
    let mut h = *h0;
    let mut m = msg.to_vec();
    m.push(0x80);
    while m.len() % 64 != 56 { m.push(0); }
    m.extend((msg.len() as u64 * 8).to_be_bytes());
    for c in m.chunks(64) {
        let mut w = [0u32; 64];
        for i in 0..16 { w[i] = u32::from_be_bytes(c[4 * i..4 * i + 4].try_into().unwrap()); }
        for i in 16..64 {
            let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
            let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
            w[i] = w[i - 16].wrapping_add(s0).wrapping_add(w[i - 7]).wrapping_add(s1);
        }
        let mut v = h;
        for i in 0..64 {
            let t1 = v[7].wrapping_add(v[4].rotate_right(6) ^ v[4].rotate_right(11) ^ v[4].rotate_right(25))
                .wrapping_add((v[4] & v[5]) ^ (!v[4] & v[6])).wrapping_add(k[i]).wrapping_add(w[i]);
            let t2 = (v[0].rotate_right(2) ^ v[0].rotate_right(13) ^ v[0].rotate_right(22))
                .wrapping_add((v[0] & v[1]) ^ (v[0] & v[2]) ^ (v[1] & v[2]));
            v = [t1.wrapping_add(t2), v[0], v[1], v[2], v[3].wrapping_add(t1), v[4], v[5], v[6]];
        }
        for i in 0..8 { h[i] = h[i].wrapping_add(v[i]); }
    }
    let mut o = [0u8; 32];
    for i in 0..8 { o[4 * i..4 * i + 4].copy_from_slice(&h[i].to_be_bytes()); }
    o
}
pub fn hmac(key: &[u8], msg: &[u8]) -> [u8; 32] {
    let mut k0 = [0u8; 64];
    if key.len() > 64 { k0[..32].copy_from_slice(&sha256(key)); } else { k0[..key.len()].copy_from_slice(key); }
    let mut inner: Vec<u8> = k0.iter().map(|b| b ^ 0x36).collect();
    inner.extend(msg);
    let mut outer: Vec<u8> = k0.iter().map(|b| b ^ 0x5c).collect();
    outer.extend(sha256(&inner));
    sha256(&outer)
}

// HKDF (RFC 5869) with HashLen = 32
pub fn hkdf_extract(salt: &[u8], ikm: &[u8]) -> [u8; 32] {
    // an absent salt means HashLen zero bytes; HMAC zero-pads keys, so empty is equivalent
    hmac(if salt.is_empty() { &[0u8; 32] } else { salt }, ikm)
}
pub fn hkdf_expand(prk: &[u8; 32], info: &[u8], len: usize) -> Result<Vec<u8>, &'static str> {
    if len > 255 * 32 { return Err("L > 255*HashLen"); }
    let (mut okm, mut t) = (Vec::with_capacity(len + 32), Vec::new());
    for i in 1..=len.div_ceil(32) {
        let mut msg = t.clone();                     // T(i-1) | info | i
        msg.extend(info);
        msg.push(i as u8);
        t = hmac(prk, &msg).to_vec();
        okm.extend(&t);
    }
    okm.truncate(len);
    Ok(okm)
}

fn hex(b: &[u8]) -> String { b.iter().map(|x| format!("{x:02x}")).collect() }
fn main() {
    let r = |a: u8, b: u8| (a..=b).collect::<Vec<u8>>();
    // RFC 5869 Appendix A.1
    let (ikm, salt, info) = (vec![0x0b; 22], r(0, 12), r(0xf0, 0xf9));
    let prk = hkdf_extract(&salt, &ikm);
    assert_eq!(hex(&prk), "077709362c2e32df0ddc3f0dc47bba6390b6c73bb50f9c3122ec844ad7c2b3e5");
    assert_eq!(hex(&hkdf_expand(&prk, &info, 42).unwrap()), "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865");
    // A.2: 80-byte inputs, 82-byte output (three blocks)
    let prk = hkdf_extract(&r(0x60, 0xaf), &r(0, 0x4f));
    assert_eq!(hex(&prk), "06a6b88c5853361a06104c9ceb35b45cef760014904671014a193f40c15fc244");
    assert_eq!(hex(&hkdf_expand(&prk, &r(0xb0, 0xff), 82).unwrap()), "b11e398dc80327a1c8e7f78c596a49344f012eda2d4efad8a050cc4c19afa97c59045a99cac7827271cb41c65e590e09da3275600c2f09b8367793a9aca3db71cc30c58179ec3e87c14c01d5c1f3434f1d87");
    // A.3: empty salt and info
    let prk = hkdf_extract(&[], &ikm);
    assert_eq!(hex(&prk), "19ef24a32c717b167f33a91d6f648bdf96596776afdb6377ac434c1c293ccb04");
    assert_eq!(hex(&hkdf_expand(&prk, &[], 42).unwrap()), "8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8");
    // limits and separation
    assert!(hkdf_expand(&prk, &[], 255 * 32).is_ok());
    assert!(hkdf_expand(&prk, &[], 255 * 32 + 1).is_err());
    let a = hkdf_expand(&prk, b"app v1 client->server key", 32).unwrap();
    let b = hkdf_expand(&prk, b"app v1 server->client key", 32).unwrap();
    assert_ne!(a, b);
    println!("RFC 5869 A.1-A.3 ok");
}

Complexity and constants

Extract is one HMAC; expand is ceil(L/32) HMACs in strict sequence (T(i) needs T(i-1)). One HMAC-SHA-256 over a short message is 4 compression calls, or 2 with cached key-pad states. Measured on an Apple M4 Pro with my unoptimised std-only code (heap allocations, no cached pads, one release run of 200,000 iterations), order of: extract plus 32-byte expand 1.9 µs; one extra 32-byte expand 1.0 µs; 1,024-byte expand 31 µs (linear in blocks); SHA-256 of 64 B 0.39 µs.

Efficiency

  • Cost: one HMAC-SHA-256 per 32 output bytes, 2 to 4 compressions; PRK is 32 B of state. Compute-bound, no memory traffic (measured above).
  • Levers:
    • Cache the PRK — skips the extract HMAC (measured 1.9 → 1.0 µs per 32-byte key) — one secret, many keys.
    • Cache the pad states of the PRK — halves compressions per block (RFC 2104 structure) — bulk derivation.
    • One long expand, then slice — same hashing, fewer calls and allocations — fixed key bundles.
    • SHA-256 hardware instructions (SHA-NI, ARMv8 SHA2) — constant-factor gain, not measured here — busy servers.
  • Naive → standard → state of the art: hand-rolled hash(secret, label, counter) · 1 hash, no extraction proof; HKDF-SHA-256 with cached PRK · 1 HMAC per 32 B; cached pad states plus hardware SHA · 2 compressions per 32 B.

Implementation notes

  • Zeroize PRK and OKM after use; derive each key just before use.
  • Length-prefix every variable-length field in info: ("ab","c") and ("a","bc") must differ (TLS 1.3 does in HkdfLabel). Start with protocol name and version, then role, purpose; add L if different lengths of one key must be unrelated.
  • Salt: a random or negotiated public value (nonce, handshake hash) beats none (RFC 5869 section 3.1); never attacker-chosen.
  • Rotation by epoch: info = label || u64 epoch. Whoever holds the master recomputes every epoch, so this bounds per-key exposure, not past secrecy. For forward secrecy chain and delete: TLS 1.3 key update derives the next secret with HKDF-Expand-Label(secret_N, "traffic upd", "", Hash.length) and deletes the old one (RFC 8446 section 7.2). See key rotation.
  • NIST SP 800-56C Rev. 2 (key-derivation methods in key establishment) lists extraction-then-expansion; SP 800-108 Rev. 1 covers PRF-based derivation (HMAC, CMAC, KMAC). Check which your policy names.

Pitfalls

  • HKDF on a password: fast, so guessable. Use Argon2 first.
  • Skipping extract on a Diffie-Hellman result (not uniform).
  • Same info for two keys: identical output. Same info, different L: outputs are prefixes of each other.
  • L above 255*HashLen must error, not wrap the one-byte counter.
  • Salt or info depending on the IKM.
  • Using PRK itself as output key when L is at most HashLen: not recommended (RFC 5869 section 3.3), info is skipped.
  • Expecting forward secrecy from info alone, or authentication from HKDF.

Variants and alternatives

HKDF-SHA-384/512bigger hash; TLS 1.3 uses the cipher suite's hash.
Expand onlymaster already uniform: per-file, per-tenant, per-device keys (envelope encryption).
BLAKE3 derive_key, KMACone-call keyed derivation with context string (hashes, MACs).
DUKPTper-transaction keys for card terminals (DUKPT).
Ratchetsnew key per message with forward secrecy (Double Ratchet).

In production

  • TLS 1.3: the key schedule is HKDF-Extract and HKDF-Expand-Label (RFC 8446 section 7.1).
  • Noise: HKDF() mixes each DH result into the chaining key.
  • WireGuard: handshake chains HMAC-based Kdf1..Kdf3, built like HKDF.
  • Signal: KDF_RK is recommended to be HKDF, root key as salt, DH output as IKM (Double Ratchet).
  • ECDH deployments: the raw shared point goes to a KDF with transcript or both public keys as info.
  • Storage and device fleets: per-object keys from a master, often HSM-held.

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
hkdf (RustCrypto)RustRustCrypto/KDFscargo add hkdf————generic over any digest; Hkdf::new + expand
ring hkdfRustbriansmith/ringcargo add ring————ring::hkdf; Salt::new, extract, expand
libsodiumCjedisct1/libsodiumbrew install libsodium————crypto_kdf_hkdf_sha256_extract / _expand
OpenSSLCopenssl/opensslbrew install openssl————EVP_KDF "HKDF" with extract-only, expand-only or both modes
x/crypto hkdfGogolang/cryptogo get golang.org/x/crypto/hkdf————hkdf.New, Extract, Expand
cryptographyPythonpyca/cryptographypip install cryptography————HKDF, HKDFExpand classes

Sources

  • Krawczyk, Eronen, HMAC-based Extract-and-Expand Key Derivation Function (HKDF), RFC 5869, 2010. rfc
  • Krawczyk, Cryptographic Extraction and Key Derivation: The HKDF Scheme, CRYPTO 2010, pp. 631-648. paper · eprint
  • Rescorla, The Transport Layer Security (TLS) Protocol Version 1.3, RFC 8446, 2018, section 7.1. rfc
  • Perrin, The Noise Protocol Framework, section 4.3. spec
  • Perrin, Marlinspike, The Double Ratchet Algorithm. spec
  • Donenfeld, WireGuard: Next Generation Kernel Network Tunnel. pdf
  • NIST SP 800-56C Rev. 2, Recommendation for Key-Derivation Methods in Key-Establishment Schemes. nist
  • NIST SP 800-108 Rev. 1, Recommendation for Key Derivation Using Pseudorandom Functions. nist

Used by

AKA (authentication and key agreement)A network operator must let millions of devices with a tamper-resistant chip join networks run by others, proving who each device is and that the network is genuine, without sharing the chip's secret with those networks.
double ratchet · plannedSignal Double Ratchet: DH and symmetric ratchets for forward secrecy
group messaging encryption · plannedSender keys and MLS TreeKEM for scalable end-to-end group encryption
onion routing · plannedLayered encryption through relay circuits: telescoping handshakes (ntor), fixed cells, stream multiplexing
TLS 1.3Any network service must keep traffic between clients, or between internal services, private, tamper-proof and authenticated to the right server, without hand-building a secure channel.
x3dh key agreement · plannedX3DH/PQXDH asynchronous initial key agreement with prekeys

Systems (20):

Alternatives and related

Message authentication codes (HMAC, CMAC, Poly1305, KMAC)A receiver gets bytes over a channel an attacker can modify (webhook, cookie, radio frame, download link) and must tell whether a holder of a shared secret produced them unchanged.ECDH key exchange (X25519, P-256)Two parties on an open network who share no secret need the same fresh symmetric key at the start of a session, without any eavesdropper being able to compute it, whether they are browsers, phones, tunnels or devices pairing.Envelope encryptionServices storing sensitive data must encrypt it at rest, rotate keys and delete data reliably, without sending every byte through a key management service.Password hashing (Argon2id)A service that stores user passwords must make a leaked database expensive to crack, since attackers can test billions of guesses per second on GPUs against ordinary hashes.
noise protocol · plannedNoise framework handshakes (IK, XX): DH patterns, chaining keys, transport ciphers
WireGuardOperators who link laptops, phones, servers and cluster nodes over untrusted networks need an encrypted tunnel that is small, quiet to scanners, survives address changes, and ties each packet's inner source address to a key.
key rotation · plannedRotating keys without downtime: key ids, overlap windows, re-wrapping, JWKS publication, revocation
Cryptographic hashes (SHA-2, BLAKE3)Systems that need a short, trustworthy fingerprint of data (file integrity, content-addressed storage, deduplication, signatures, Merkle trees) must pick a hash that attackers cannot forge collisions for.