javax.crypto.KDF, and in JDK 25 it is final, with no preview flag. This guide to the Java KDF API and HKDF starts from “what is a KDF” and ends with per-tenant keys, a hand-written second implementation, and comparisons against Python and OpenSSL. Every number below comes from a run, and the code is in the kdf module of javademos.
Versions tested. Temurin 25.0.5+8 (JDK 25 is an LTS release), no--enable-preview. Pythoncryptography50.0.1 and theopenssl3.0.13 command line were used only as comparison tools.javax.crypto.KDFcarries@since 25and no@PreviewFeatureannotation in this JDK (output/01). The JEP page itself (openjdk.org/jeps/510) lists Status Closed / Delivered, Release 25, and relates it to JEP 478, the JDK 24 preview. I did not have a JDK 24 to run, so I make no claims about the preview’s behaviour.
A KDF turns one secret into many keys, safely
A KDF takes some secret input (called input key material, IKM), a bit of context, and a length, and returns that many bytes that look random. Two properties matter. It is deterministic: the same inputs always give the same output, so you never need to store the derived key. And it is one-way and context-sensitive: knowing one derived key tells you nothing about the master key or about a key derived with different context. HKDF (RFC 5869) is the KDF to use when your input is already a strong secret: a random 256-bit master key from a KMS, a Diffie-Hellman shared secret, a session key. It works in two steps built on HMAC. Extract mixes the secret with an optional salt into a uniformly random pseudorandom key (PRK). Expand stretches the PRK into as many bytes as you ask for, steered by an info string that says what the key is for.info) is what makes “AES key for tenant A” and “HMAC key for tenant A” two unrelated keys even though they come from the same PRK. Keep that sentence in mind: the secret comes from IKM, the separation comes from info. Most of the mistakes later in the post are one of those two roles being filled by the wrong input.
Going deeper: what the salt is really for
Extract exists because real secrets are not always uniformly random: a Diffie-Hellman result has structure, and the concatenation of two secrets is lumpy. HMAC with a salt as key smooths that into a uniform PRK. A random salt strengthens this, but it is not secret and may be reused, and when you have none, RFC 5869 says to use HashLen zero bytes. If your IKM is already a uniformly random key (a 256-bit key from your KMS), the RFC allows skipping Extract and going straight to Expand; the JDK exposes this asexpandOnly.
- RFC 5869, sections 3.1 to 3.4 on the salt, info and when to skip Extract
- Krawczyk, “Cryptographic Extraction and Key Derivation: The HKDF Scheme”, the design paper
The smallest thing that works
Here is the first RFC 5869 test vector (22 bytes of0x0b as the secret, a 13-byte salt, a 10-byte info, 42 bytes out) through the JDK 25 API. From src/ApiTour.java:
byte[] ikm = Fx.fill(0x0b, 22), salt = Fx.range(0, 13), info = Fx.range(0xf0, 10);
Fx.header("Shape 1: extract then expand in one call (the common case)");
HKDFParameterSpec both = HKDFParameterSpec.ofExtract().addIKM(ikm).addSalt(salt).thenExpand(info, 42);
byte[] okm = kdf.deriveData(both);
System.out.println("deriveData -> byte[" + okm.length + "] " + Fx.hex(okm));
HKDFParameterSpec.ofExtract() starts a builder; you add the secret and salt, then thenExpand(info, length) says “also run Expand” and finishes the spec. deriveData hands back raw bytes. Use deriveKey("AES", spec) instead when you want a SecretKey that you can pass straight to a Cipher. The output, from output/02-api-tour.txt:
== Shape 1: extract then expand in one call (the common case)
deriveData -> byte[42] 3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865
deriveKey -> algorithm=AES format=RAW bytes=32
== Shape 2: extract only (returns the pseudorandom key, PRK)
PRK = 077709362c2e32df0ddc3f0dc47bba6390b6c73bb50f9c3122ec844ad7c2b3e5
The 42 bytes at the top are exactly the OKM printed in RFC 5869 appendix A.1, and the PRK matches the RFC’s PRK. The next-but-one section shows that this is asserted, not eyeballed.
The API in one table: three spec shapes, three algorithm names
The JDK offers three shapes of request, all built fromHKDFParameterSpec:
| You want | Build it with | Returns |
|---|---|---|
| Secret to keys (normal case) | ofExtract().addIKM(..).addSalt(..).thenExpand(info, len) | OKM bytes or a SecretKey |
| Only the PRK | ofExtract().addIKM(..).addSalt(..).extractOnly() | the PRK as a SecretKey |
| Only Expand, from a key you already trust | HKDFParameterSpec.expandOnly(prk, info, len) | OKM bytes or a SecretKey |
SunJCE provider. Anything else, including plain "HKDF" without a hash and SHA-1 variants, is a NoSuchAlgorithmException:
From output/02-api-tour.txt:
== KDF algorithms registered by the providers of this JDK
SunJCE HKDF-SHA256
SunJCE HKDF-SHA384
SunJCE HKDF-SHA512
== getInstance: what works and what does not
algorithm=HKDF-SHA256 provider=SunJCE parameters=null
HKDF -> NoSuchAlgorithmException: No available provider supports the specified algorithm
HKDF-SHA1 -> NoSuchAlgorithmException: No available provider supports the specified algorithm
hkdf-sha256 -> available
PBKDF2WithHmacSHA256 -> NoSuchAlgorithmException: No available provider supports the specified algorithm
Argon2 -> NoSuchAlgorithmException: No available provider supports the specified algorithm
Two behaviours surprised me while writing the demos. First, deriveKey("AES", ..., 20 bytes) succeeds: the KDF does not know AES key sizes, and the error only appears later at Cipher.init (output/02-api-tour.txt, last block). Second, algorithm names are case-insensitive, as everywhere in JCA (hkdf-sha256 worked).
Do not share oneKDFobject between threads. The class Javadoc says KDF objects are not thread-safe. They are cheap to create, so callKDF.getInstance("HKDF-SHA256")inside the method that derives, as Tenants.java does.
Going deeper: the complete API surface and the provider SPI
The output below isjavap on JDK 25.0.5, taken from output/01-api-shape.txt. KDFParameters is an empty marker interface and HKDF in the JDK does not use it (getParameters() returned null above). KDFSpi is the extension point a provider implements to add another KDF; the JDK itself ships only HKDF. PBKDF2 stays where it always was, SecretKeyFactory.
$ javap javax.crypto.KDF javax.crypto.KDFParameters javax.crypto.KDFSpi javax.crypto.spec.HKDFParameterSpec 'javax.crypto.spec.HKDFParameterSpec$Builder' ... (25.0.5+8-LTS)
public final class javax.crypto.KDF {
static final boolean $assertionsDisabled;
public java.lang.String getAlgorithm();
public java.lang.String getProviderName();
public javax.crypto.KDFParameters getParameters();
public static javax.crypto.KDF getInstance(java.lang.String) throws java.security.NoSuchAlgorithmException;
public static javax.crypto.KDF getInstance(java.lang.String, java.lang.String) throws java.security.NoSuchAlgorithmException, java.security.NoSuchProviderException;
public static javax.crypto.KDF getInstance(java.lang.String, java.security.Provider) throws java.security.NoSuchAlgorithmException;
public static javax.crypto.KDF getInstance(java.lang.String, javax.crypto.KDFParameters) throws java.security.NoSuchAlgorithmException, java.security.InvalidAlgorithmParameterException;
public static javax.crypto.KDF getInstance(java.lang.String, javax.crypto.KDFParameters, java.lang.String) throws java.security.NoSuchAlgorithmException, java.security.NoSuchProviderException, java.security.InvalidAlgorithmParameterException;
public static javax.crypto.KDF getInstance(java.lang.String, javax.crypto.KDFParameters, java.security.Provider) throws java.security.NoSuchAlgorithmException, java.security.InvalidAlgorithmParameterException;
public javax.crypto.SecretKey deriveKey(java.lang.String, java.security.spec.AlgorithmParameterSpec) throws java.security.InvalidAlgorithmParameterException, java.security.NoSuchAlgorithmException;
public byte[] deriveData(java.security.spec.AlgorithmParameterSpec) throws java.security.InvalidAlgorithmParameterException;
static {};
}
public interface javax.crypto.KDFParameters {
}
public abstract class javax.crypto.KDFSpi {
protected javax.crypto.KDFSpi(javax.crypto.KDFParameters) throws java.security.InvalidAlgorithmParameterException;
protected abstract javax.crypto.KDFParameters engineGetParameters();
protected abstract javax.crypto.SecretKey engineDeriveKey(java.lang.String, java.security.spec.AlgorithmParameterSpec) throws java.security.InvalidAlgorithmParameterException, java.security.NoSuchAlgorithmException;
protected abstract byte[] engineDeriveData(java.security.spec.AlgorithmParameterSpec) throws java.security.InvalidAlgorithmParameterException;
}
public interface javax.crypto.spec.HKDFParameterSpec extends java.security.spec.AlgorithmParameterSpec {
public static javax.crypto.spec.HKDFParameterSpec$Builder ofExtract();
public static javax.crypto.spec.HKDFParameterSpec$Expand expandOnly(javax.crypto.SecretKey, byte[], int);
}
public final class javax.crypto.spec.HKDFParameterSpec$Builder {
public javax.crypto.spec.HKDFParameterSpec$Extract extractOnly();
public javax.crypto.spec.HKDFParameterSpec$ExtractThenExpand thenExpand(byte[], int);
public javax.crypto.spec.HKDFParameterSpec$Builder addIKM(javax.crypto.SecretKey);
public javax.crypto.spec.HKDFParameterSpec$Builder addIKM(byte[]);
public javax.crypto.spec.HKDFParameterSpec$Builder addSalt(javax.crypto.SecretKey);
public javax.crypto.spec.HKDFParameterSpec$Builder addSalt(byte[]);
}
public final class javax.crypto.spec.HKDFParameterSpec$Extract implements javax.crypto.spec.HKDFParameterSpec {
public java.util.List<javax.crypto.SecretKey> ikms();
public java.util.List<javax.crypto.SecretKey> salts();
}
public final class javax.crypto.spec.HKDFParameterSpec$Expand implements javax.crypto.spec.HKDFParameterSpec {
public javax.crypto.SecretKey prk();
public byte[] info();
public int length();
}
public final class javax.crypto.spec.HKDFParameterSpec$ExtractThenExpand implements javax.crypto.spec.HKDFParameterSpec {
public java.util.List<javax.crypto.SecretKey> ikms();
public java.util.List<javax.crypto.SecretKey> salts();
public byte[] info();
public int length();
}
- javax.crypto.KDF Javadoc (JDK 25)
- HKDFParameterSpec Javadoc
- JEP 510 (status Closed / Delivered, release 25)
Under the hood: Expand is a short HMAC loop
You should be able to trust a cryptographic API only as far as you can explain it, so here is HKDF with nothing butjavax.crypto.Mac. Extract is one HMAC call. Expand chains blocks: T(1) = HMAC(PRK, info | 0x01), T(2) = HMAC(PRK, T(1) | info | 0x02), and so on, and the output is the first L bytes of T(1) | T(2) | .... From src/RefHkdf.java:
/** HKDF-Extract: PRK = HMAC-Hash(salt, IKM); an absent salt is HashLen zero bytes. */
static byte[] extract(String hmac, byte[] salt, byte[] ikm) throws Exception {
Mac mac = Mac.getInstance(hmac);
byte[] s = (salt == null || salt.length == 0) ? new byte[mac.getMacLength()] : salt;
mac.init(new SecretKeySpec(s, hmac));
return mac.doFinal(ikm);
}
/** HKDF-Expand: T(i) = HMAC(PRK, T(i-1) | info | i), OKM = first L bytes of T(1) | T(2) | ... */
static byte[] expand(String hmac, byte[] prk, byte[] info, int length) throws Exception {
Mac mac = Mac.getInstance(hmac);
int hashLen = mac.getMacLength();
if (length > 255 * hashLen) throw new IllegalArgumentException("L > 255*HashLen");
mac.init(new SecretKeySpec(prk, hmac));
ByteArrayOutputStream okm = new ByteArrayOutputStream();
byte[] t = new byte[0];
for (int i = 1; okm.size() < length; i++) {
mac.update(t);
mac.update(info);
mac.update((byte) i);
t = mac.doFinal();
okm.write(t);
}
return Arrays.copyOf(okm.toByteArray(), length);
}
The single byte counter is why the output length is capped at 255 × hash length: 8,160 bytes for SHA-256, 16,320 for SHA-512. The JDK enforces this, though its error message is vague (shown in the mistakes section below). This reference class is also the independent second implementation used in the next section.
How to know it is correct: RFC vectors and two outside implementations
“It produces 42 bytes” proves nothing. Correctness here means “it matches what every other HKDF produces”, and there are three kinds of evidence, each stronger than the last. All of it is checked on every run of run.sh; a mismatch fails the script. 1. The RFC 5869 test vectors. Appendix A has SHA-256 cases (1 to 3) and SHA-1 cases (4 to 7). The JDK has no SHA-1 HKDF, so cases 4 to 7 cannot run, but 1 to 3 do, each asserted three ways: the one-call OKM, the extract-only PRK, and the OKM fromexpandOnly on that PRK. From src/Rfc5869Vectors.java and output/03-rfc5869-vectors.txt:
== RFC 5869 A.1 basic (IKM 22 B, salt 13 B, info 10 B, L=42)
PASS extract+expand -> OKM
PASS extractOnly -> PRK
PASS expandOnly(PRK) -> OKM
== RFC 5869 A.2 long inputs (IKM 80 B, salt 80 B, info 80 B, L=82)
PASS extract+expand -> OKM
PASS extractOnly -> PRK
PASS expandOnly(PRK) -> OKM
== RFC 5869 A.3 empty salt and info (IKM 22 B, salt 0 B, info 0 B, L=42)
PASS extract+expand -> OKM
PASS extractOnly -> PRK
PASS expandOnly(PRK) -> OKM
2. A hand-written reference. The RefHkdf class above is first checked against the same RFC vectors, so it is known good, and then used as an oracle on 300 generated cases. It still uses the JDK’s HMAC, so it shares the HMAC code with the KDF, but not the HKDF code.
3. Different people’s code. interop/gen_cases.py uses Python’s cryptography package to compute the expected output for 300 random inputs (100 each for SHA-256, SHA-384, SHA-512; empty salt and empty info included; output lengths from 1 byte up to the 255×HashLen maximum). Java reads interop/cases.tsv and compares both the JDK API and RefHkdf with Python’s answer. Then interop/openssl_check.py asks the openssl kdf command, which involves neither Java nor Python, for the two RFC cases and every tenth row. From output/04 and output/05:
$ java -cp classes Interop interop/cases.tsv (25.0.5+8-LTS)
== interop/cases.tsv: 300 cases {SHA256=100, SHA384=100, SHA512=100}, longest output 16320 bytes
PASS JDK KDF API matches Python cryptography on 300 of 300
PASS hand-written RefHkdf matches Python cryptography on 300 of 300
python cryptography 50.0.1
wrote 300 cases
cases.tsv regenerates byte-identically
OpenSSL 3.0.13 30 Jan 2024 (Library: OpenSSL 3.0.13 30 Jan 2024)
openssl kdf HKDF: 32 of 32 cases match (2 RFC 5869 + every 10th row of cases.tsv)
All agree. This is the pattern to copy if you ever store data under derived keys: pin the derivation with vectors from outside your codebase, because a key derived differently after an upgrade is data you can no longer decrypt. Note what this does not prove: it shows the JDK computes standard HKDF, not that your info design is sound.
Going deeper: reproducing the openssl and Python side by hand
The openssl 3.x CLI exposes HKDF asopenssl kdf. The exact command used inside openssl_check.py for RFC case A.1 is:
openssl kdf -keylen 42 -kdfopt digest:SHA256 -kdfopt hexkey:0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b -kdfopt hexinfo:f0f1f2f3f4f5f6f7f8f9 -kdfopt mode:EXTRACT_AND_EXPAND -kdfopt hexsalt:000102030405060708090a0b0c HKDF
This command is quoted from the script; its result is one of the matches counted in output/05. I only ran the EXTRACT_AND_EXPAND mode; openssl documents two more modes, EXTRACT_ONLY and EXPAND_ONLY, which I did not run. Python’s version is HKDF(algorithm=hashes.SHA256(), length=42, salt=salt or None, info=info).derive(ikm), as in gen_cases.py.
Per-tenant keys: put the context in info
Now the job from the opening. A multi-tenant service has one master key in a KMS and needs, for every tenant, an AES key to encrypt data and an HMAC key to sign tokens, and later a rotated version of each. The design in src/Tenants.java: the master key is the IKM, a constant application string is the salt, and everything that distinguishes one key from another goes into info.
info. The code:
/** Unambiguous info: a fixed label, then each field prefixed with its 2-byte length. */
static byte[] info(String purpose, String tenant, int keyVersion) {
byte[][] fields = {
"acme-billing/v1".getBytes(StandardCharsets.UTF_8),
purpose.getBytes(StandardCharsets.UTF_8),
tenant.getBytes(StandardCharsets.UTF_8),
ByteBuffer.allocate(4).putInt(keyVersion).array()};
int size = 0;
for (byte[] f : fields) size += 2 + f.length;
ByteBuffer b = ByteBuffer.allocate(size);
for (byte[] f : fields) b.putShort((short) f.length).put(f);
return b.array();
}
static SecretKey derive(SecretKey master, String alg, int bytes, String purpose, String tenant, int version)
throws GeneralSecurityException {
KDF kdf = KDF.getInstance("HKDF-SHA256");
return kdf.deriveKey(alg, HKDFParameterSpec.ofExtract()
.addIKM(master).addSalt(APP_SALT).thenExpand(info(purpose, tenant, version), bytes));
}
Note the info encoding. It is not "acme-billing:aes-gcm:tenant-acme:1"; each field is prefixed with its length, for a reason the next section proves. The result of running it, from output/06-tenants.txt (first 8 bytes of each key shown, and the master key is a fixed demo value):
== Per-tenant, per-purpose, per-version keys (first 8 bytes shown)
tenant-acme aes v1 = 503b6a158de948ef hmac v1 = de11af197467596c aes v2 = 0c80331430e74d8d
tenant-globex aes v1 = 3e7dd7c93250c77e hmac v1 = ab1c9c0b01b4e64b aes v2 = 22859d1db92cf04b
PASS same inputs give the same key every time (nothing to store)
PASS two tenants get different keys
PASS encryption key and MAC key of one tenant differ
PASS key version 2 differs from version 1 (rotation)
== Isolation in practice: tenant A's ciphertext is opaque to tenant B's key
acme key decrypts: invoice #4711 for acme
globex key fails: AEADBadTagException: Tag mismatch
PASS tenant B cannot open tenant A's data
Tenant globex’s key cannot decrypt acme’s ciphertext: AES-GCM reports Tag mismatch. Isolation here is a property of the derivation, so a bug in your query code that fetches the wrong tenant’s row still yields unreadable bytes.
Going deeper: where the master key lives, rotation and the blast radius
HKDF does not make a leaked master key safe: everything derived from it falls with it. Keep the master in a KMS or HSM, and consider letting the KMS do the derivation if it supports it. Rotation by version number is cheap for new data, but old ciphertext needs the old version, so store the version (or a key id) next to each ciphertext; that is why version is part ofinfo and not a global setting. If tenants must be able to demand “destroy my data”, derivation alone cannot give crypto-shredding, because the master can always re-derive the tenant key; store a per-tenant random key (wrapped by the master) for that requirement.
What goes wrong: the mistakes, reproduced
Each of these is a real, silent failure: nothing throws, and the result still looks like a key. Code in src/Mistakes.java, output in output/07-mistakes.txt.
Reusing info across purposes
If the encryption key and the MAC key are derived with the same info, they are the same key. That breaks the assumption of most constructions that use two independent keys.
== 1. Same info for two purposes: the 'two' keys are one key
PASS encryption key == MAC key when info is the same
PASS distinct info labels give distinct keys
Building info by concatenating fields
Tenant "ab" with suffix "c" and tenant "a" with suffix "bc" both produce "abc", hence the same key, hence tenants who can read each other’s data. Separators help only if they cannot appear in a field. Length-prefixing, as in the info method above, has no such condition.
== 2. Gluing fields together without a delimiter: tenant 'ab' + 'c' collides with 'a' + 'bc'
PASS naive concatenation collides
PASS length-prefixed fields (Tenants.info) do not collide
Misunderstanding the salt
Leaving the salt out is not an error; it means a string of zero bytes. The demo showsno salt and 32 zero bytes give identical output. The opposite mistake is treating the salt as the tenant separator or as a secret. Salt is public and per-application or per-session; put the tenant in info. A fixed salt shared by all tenants is fine; changing the salt later changes every key.
== 3. Salt: leaving it out is the same as HashLen zero bytes, and a salt is not a secret
PASS no salt == 32 zero bytes (SHA-256)
PASS a different salt changes every output, so a salt change is a key change
Asking for too much, and the vague error
== 4. Asking for more than 255 * HashLen bytes
length 8160 -> ok (8160 bytes)
length 8161 -> InvalidAlgorithmParameterException: The specified SunJCE provider does not support this input
8,160 bytes works for SHA-256 and 8,161 fails with InvalidAlgorithmParameterException: The specified SunJCE provider does not support this input, which does not mention the 255-block limit. In practice you need 16 to 64 bytes per key, so if you hit this you are probably using HKDF as a random-number generator.
Deriving from a password or PIN
HKDF is not a password hash. It is designed to be fast, because its input is assumed to be a strong secret. If the input is a 4-digit PIN there are only 10,000 candidates, and a fast KDF lets an attacker try them all in milliseconds. The demo derives a key from the PIN4821 with HKDF, then plays the attacker. For comparison it times PBKDF2 with 600,000 iterations, which is deliberately slow:
== 5. Deriving a key from a low-entropy secret (a 4-digit PIN)
HKDF-derived key: attacker finds PIN 4821 after 4822 guesses
PASS PBKDF2 (600000 iterations) is at least 1000 times slower per guess than HKDF
The HKDF-derived key falls after 4,822 guesses. The search space is only 10,000 PINs, so slow hashing can make each guess cost more but cannot shrink the space; the check line records that PBKDF2 at 600,000 iterations costs at least 1,000 times more per guess than HKDF on this machine. I removed the exact guesses-per-second figures from the committed output because they drift on a shared machine. For passwords use PBKDF2 via SecretKeyFactory (or Argon2 or scrypt from a library); you can then feed that strong result into HKDF as IKM if you need several keys from it.
Should you use HKDF directly at all? Yes for deriving subkeys from a strong secret, for protocol key schedules and for tenant or purpose separation. No for passwords, no as a replacement for a KMS, and no if your library (Tink, AWS Encryption SDK, your database driver) already derives keys for you; using theirs avoids designing your own info format. Also be honest about scope: this post covers the JDK API and HKDF, not a security review of your design. The checks above show the JDK matches the standard on the cases I ran.
Going deeper: smaller edge cases
- Multiple
addIKMoraddSaltcalls concatenate in the order added, and empty arrays are discarded (Javadoc); the demo checks that 10 + 12 bytes equal one 22-byte IKM (output/02). - RFC 5869 SHA-1 cases cannot run: the JDK only registers SHA-256, 384 and 512.
- The master key can be passed as a
SecretKeydirectly (addIKM(SecretKey)), which keeps it out of abyte[]you must then wipe; the demo uses that inTenants. - JDK 24 had this API as a preview (JEP 478); code compiled there with preview enabled must be recompiled on 25. I did not run JDK 24.
- Related: the PEM encodings post for loading the keys you might wrap, the FIPS 140-3 post for provider constraints, and the Java 21 to 25 features guide.
Further reading
- Companion code: javademos/kdf and its README
- run.sh, which regenerates every output file
- RFC 5869: HMAC-based Extract-and-Expand Key Derivation Function
- JEP 510: Key Derivation Function API and JEP 478 (the preview)
- javax.crypto.KDF Javadoc
- PEM Encodings in Java (JEP 538/542) and Java 21 to 25 LTS features
No Comments yet!