KDFs (HKDF / PBKDF2 / scrypt)
A Key Derivation Function turns a password (low entropy, user-typed) or a key (raw bytes) into one or more cryptographic keys. Argon2id and scrypt are the modern password KDFs; HKDF is the right tool for deriving sub-keys from an existing high-entropy secret.
Argon2id, scrypt, HKDF, parameters
EXAMPLE
// SCENARIO — password verification + per-purpose subkey derivation.
// ─── Password KDF — Argon2id (recommended) ─────────────────────
// Node — argon2 package wraps libargon2
// npm i argon2
import argon2 from 'argon2';
const HASH_OPTIONS = {
type: argon2.argon2id,
memoryCost: 1 << 16, // 64 MB — OWASP 2024 baseline
timeCost: 3, // 3 iterations
parallelism: 1,
hashLength: 32, // 32 bytes of output
};
// Hash on signup
const hash = await argon2.hash(password, HASH_OPTIONS);
// store hash as a string — already contains salt + params + variant identifier
// Verify on login
const ok = await argon2.verify(hashFromDb, password);
// Tune params: aim for ~250 ms per hash on your prod hardware.
// If signup latency is OK and login is OK, you have enough work factor.
// Increase memoryCost faster than timeCost — memory hardness > sheer CPU.
// Upgrade-on-login pattern
if (ok && argon2.needsRehash(hashFromDb, HASH_OPTIONS)) {
const rehashed = await argon2.hash(password, HASH_OPTIONS);
await db.user.update({ where: { id }, data: { passwordHash: rehashed } });
}
// ─── Password KDF — scrypt (Node built-in alternative) ──────────
import crypto from 'node:crypto';
function scryptHash(password) {
return new Promise((resolve, reject) => {
const salt = crypto.randomBytes(16);
crypto.scrypt(password, salt, 32, { N: 2 ** 16, r: 8, p: 1, maxmem: 128 * 1024 * 1024 }, (err, key) => {
if (err) reject(err);
resolve(`scrypt$N=65536,r=8,p=1$${salt.toString('base64')}$${key.toString('base64')}`);
});
});
}
function scryptVerify(stored, password) {
return new Promise((resolve, reject) => {
const [, params, saltB64, keyB64] = stored.split('$');
const opts = Object.fromEntries(params.split(',').map((p) => {
const [k, v] = p.split('=');
return [k, parseInt(v, 10)];
}));
const salt = Buffer.from(saltB64, 'base64');
const key = Buffer.from(keyB64, 'base64');
crypto.scrypt(password, salt, key.length, { ...opts, maxmem: 128 * 1024 * 1024 }, (err, derived) => {
if (err) return reject(err);
resolve(crypto.timingSafeEqual(derived, key));
});
});
}
// ─── Password KDF — PBKDF2 (legacy / FIPS) ─────────────────────
// Use only when Argon2id or scrypt aren't allowed (FIPS environments).
// OWASP 2024: 600,000 iterations for SHA-256, 210,000 for SHA-512.
const salt = crypto.randomBytes(16);
const hashBuf = crypto.pbkdf2Sync(password, salt, 600_000, 32, 'sha256');
// ─── HKDF — derive sub-keys from an existing high-entropy secret ─
// Use HKDF when you ALREADY have key material (a session key, an HSM-derived
// secret, an exchanged ECDH shared secret) and want to derive labelled sub-keys.
// HKDF is NOT a password KDF — it's fast on purpose. Don't feed it a password.
import { hkdfSync } from 'node:crypto';
const masterKey = Buffer.from(process.env.MASTER_KEY_B64, 'base64'); // 32+ bytes
function subkey(label, length = 32) {
return Buffer.from(hkdfSync('sha256', masterKey, Buffer.alloc(0), Buffer.from(label, 'utf8'), length));
}
const encryptKey = subkey('aead:encrypt:v1');
const macKey = subkey('mac:v1');
const cookieKey = subkey('cookie:v1');
// Why HKDF: a single master key spawns many independent keys, none of which
// reveal the others — and rotating the master invalidates everything atomically.
// ─── Per-user / per-tenant key derivation ───────────────────────
function tenantKey(tenantId) {
const salt = Buffer.from(tenantId, 'utf8');
const info = Buffer.from('tenant-encryption:v1', 'utf8');
return Buffer.from(hkdfSync('sha256', masterKey, salt, info, 32));
}
// Encrypt with AES-GCM using the tenant key
import { createCipheriv, randomBytes } from 'node:crypto';
function encrypt(tenantId, plaintext) {
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', tenantKey(tenantId), iv);
const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
return Buffer.concat([iv, cipher.getAuthTag(), ct]).toString('base64');
}
// ─── Parameter selection cheat sheet (2024 baseline) ────────────
// Argon2id: m = 64 MB, t = 3, p = 1 — bump m up first
// scrypt: N = 2^16, r = 8, p = 1 — N quadratic in time
// bcrypt: cost = 12-14 — old but still acceptable
// PBKDF2: iters = 600_000 (SHA-256) — only when forced
// HKDF: SHA-256, salt = per-purpose label or random per-record
// ─── Storage format ────────────────────────────────────────────
// argon2 .hash() output already encodes algorithm + params + salt + tag.
// For your own format, store: $algo$params$saltB64$hashB64
// Never store: just the hex of the hash with no salt or params.
// Migrate by storing the algo+params so upgrades roll forward cleanly.
// ─── Login flow checklist ──────────────────────────────────────
// ✓ Compare with crypto.timingSafeEqual / library verify (constant time)
// ✓ Always run the KDF — even when the username is unknown
// ✓ Rate-limit per IP + per account (different windows)
// ✓ Lockout exponential backoff, not hard lockouts (denial-of-service vector)
// ✓ Upgrade-on-login when params change
// ✓ Log only the username + IP + outcome, never the password or its hash
// ─── Common bugs ───────────────────────────────────────────────
// • Storing MD5 / SHA-1 / plain SHA-256 of password — not a KDF, instant break
// • Using HKDF where Argon2id is needed — HKDF is fast on purpose
// • Using Argon2id with default params on a tiny VM — login takes 3 s, OOMs the box
// • Forgetting per-record salt — pre-computed rainbow-table attacks succeed
// • Skipping KDF on unknown-username — leaks user existence via timing
// • PBKDF2 with iters = 1000 — that's 2010 advice, ten times below modern guidance
// • Returning the password hash in an API response — exposes the parameter strength to attackers
Why it matters
Use Argon2id (or scrypt) for passwords with parameters tuned to ~250 ms on prod hardware, and HKDF for deriving labelled sub-keys from an existing high-entropy secret — never the other way around. Always run the KDF even on unknown usernames to avoid timing leaks, and rehash on login when your parameter floor moves up.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
// HKDF — derive multiple keys from one secret (HMAC-based). // PBKDF2 — legacy password-based KDF; minimum 600k iters / SHA-256. // scrypt — memory-hard; good when argon2 isn't available.Try it Yourself »
Exercise
Modern HMAC-based key-derivation function.
Four letters.
Discussion
Loading…