You need a 32-byte AES key from a payment-processor secret. Code review finds Arrays.copyOf(hmac.doFinal(salt), 32), or a forty-line HKDF pasted from StackOverflow with a comment that says “don’t touch this.”
That is not a key derivation function. That is a guess that happens to look like one.
Java 25 makes HKDF a platform API with KDF, finalized by JEP 510. You derive keys from a secret, a salt, and an info string — you do not invent a KDF.
KDF hkdf = KDF.getInstance("HKDF-SHA256");
SecretKey aesKey = hkdf.deriveKey("AES",
HKDFParameterSpec.ofExtract()
.addIKM(processorSecret)
.addSalt(salt)
.thenExpand(info, 32));
The problem it solves
Two parties that share a secret still need keys: AES-256 for a checkout blob, HMAC-SHA256 for a webhook, maybe a second AES key after a rotation. The secret is high-entropy input keying material (IKM). It is not yet a key with the right length, algorithm name, and domain.
The usual glue is all bad:
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(processorSecret, "HmacSHA256"));
byte[] maybeKey = Arrays.copyOf(mac.doFinal(salt), 32);
HMAC-then-truncate is not HKDF. It skips extract/expand, has no info for domain separation, and quietly reuses one digest as every key you later need. The StackOverflow copy is worse: a missed salt concat or a swapped extract/expand step is a vulnerability you will not notice in a unit test that only checks round-trip encryption.
javax.crypto.KDF is the JCA type for this job. JDK 25 ships HKDF (RFC 5869) under HKDF-SHA256, HKDF-SHA384, and HKDF-SHA512. You ask for an algorithm, pass IKM + salt + info, and get a SecretKey or raw bytes.
When it shipped
| Release | Status | Spec |
|---|---|---|
| Java 24 | Preview | JEP 478 |
| Java 25 LTS | Final | JEP 510 |
No preview flag on Java 25+. The API lives in java.base (javax.crypto.KDF, javax.crypto.spec.HKDFParameterSpec), so there is no module to require and no dependency to declare. JEP 510 is explicit: the Java 25 API matches the Java 24 preview — no rename tax at finalization.
Note: Password-based PBKDF1 and PBKDF2 stay on SecretKeyFactory. That is a JEP non-goal, not an omission you should paper over by stuffing a password into HKDF.
Mental model
HKDF has two stages. Most application code runs both in one call (thenExpand). The names in the API match RFC 5869.
| Input | Role | Secret? |
|---|---|---|
| IKM | Shared secret, KEM output, vault bytes | Yes |
| Salt | Randomizes extract; stored next to ciphertext is fine | No |
| Info | Context string that names the key’s job | No |
| Length | Output size in bytes (32 for AES-256) | — |
Extract mixes IKM and salt into a pseudorandom key (PRK). Expand stretches that PRK into output keying material, using info so checkout:enc:v1 and checkout:mac:v1 never collide.
IKM + salt --extract--> PRK --expand(info, L)--> key bytes
Three HKDFParameterSpec shapes cover the modes:
| Spec | Factory | When |
|---|---|---|
| Extract-then-expand | ofExtract()…thenExpand(info, L) | Default for app secrets |
| Extract only | ofExtract()…extractOnly() | You want the PRK, then several expands |
| Expand only | expandOnly(prk, info, L) | You already have a PRK |
Same IKM, different info, different keys. That is the whole point of a KDF versus hashing the secret once and hoping.
Not MessageDigest. Not SecretKeyFactory.
MessageDigest hashes a byte sequence. It has no extract step, no RFC 5869 info, and no typed SecretKey. This is a digest of the secret, not a derived key:
byte[] notAKey = MessageDigest.getInstance("SHA-256").digest(processorSecret);
SecretKeyFactory is built to produce one key from a spec. That is the right home for a password: low entropy, so you need a slow stretch (PBKDF2 today; Argon2 is called out as future work on KDF, not shipped in 25).
SecretKeyFactory pbe = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
SecretKey fromPassword = pbe.generateSecret(
new PBEKeySpec(userPassword, salt, 210_000, 256));
KDF is the opposite shape: two parties with the same high-entropy IKM must derive the same bytes, possibly several keys, without injecting extra SecureRandom entropy into the output. KeyGenerator is the type that creates a fresh random key. Do not use it when both sides must match.
| You have | Reach for |
|---|---|
| High-entropy secret, need AES/HMAC keys | KDF + HKDF |
| User password | SecretKeyFactory + PBKDF2 (until Argon2) |
| Need a brand-new random key | KeyGenerator / SecureRandom |
| Need a fingerprint of bytes | MessageDigest |
Do not feed a user password to HKDF. HKDF is fast on purpose. Fast on a password is a brute-force gift.
Derive a checkout AES key
The lab is one JDK 25 and a processor secret you already store in a vault. IKM is 32 random bytes here so the file runs; in production those bytes come from the vault, not from nextBytes at request time.
import javax.crypto.Cipher;
import javax.crypto.KDF;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.HKDFParameterSpec;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.security.spec.AlgorithmParameterSpec;
public class CheckoutKeys {
static final byte[] INFO_ENC =
"checkout:aes-gcm:v1".getBytes(StandardCharsets.UTF_8);
public static void main(String[] args) throws Exception {
byte[] ikm = new byte[32];
byte[] salt = new byte[32];
SecureRandom rng = SecureRandom.getInstanceStrong();
rng.nextBytes(ikm); // stand-in for vault.get("processor-secret")
rng.nextBytes(salt); // persist this next to the ciphertext
KDF hkdf = KDF.getInstance("HKDF-SHA256");
AlgorithmParameterSpec spec = HKDFParameterSpec.ofExtract()
.addIKM(ikm)
.addSalt(salt)
.thenExpand(INFO_ENC, 32);
SecretKey aesKey = hkdf.deriveKey("AES", spec);
byte[] iv = new byte[12];
rng.nextBytes(iv);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, aesKey, new GCMParameterSpec(128, iv));
byte[] blob = cipher.doFinal("order-42".getBytes(StandardCharsets.UTF_8));
System.out.println("key alg=" + aesKey.getAlgorithm()
+ " cipherLen=" + blob.length);
}
}
Run it from source:
java CheckoutKeys.java
key alg=AES cipherLen=24
deriveKey("AES", spec) labels the SecretKey for Cipher. The 32 in thenExpand is the AES-256 length in bytes. The IV is random, not derived — a derived IV that repeats is how GCM demos become incidents.
addIKM and addSalt also accept a SecretKey, and you can call them more than once. That is how labeled extract concatenates a public label with a non-extractable HSM secret without copying the secret into a byte[].
Two keys, one secret
Checkout still needs a MAC key that must never be the AES key. Change info, keep IKM and salt. Extract once, expand twice:
KDF hkdf = KDF.getInstance("HKDF-SHA256");
byte[] prkBytes = hkdf.deriveData(
HKDFParameterSpec.ofExtract()
.addIKM(ikm)
.addSalt(salt)
.extractOnly());
SecretKey prk = new javax.crypto.spec.SecretKeySpec(prkBytes, "Generic");
SecretKey encKey = hkdf.deriveKey("AES",
HKDFParameterSpec.expandOnly(prk,
"checkout:enc:v1".getBytes(StandardCharsets.UTF_8), 32));
SecretKey macKey = hkdf.deriveKey("HmacSHA256",
HKDFParameterSpec.expandOnly(prk,
"checkout:mac:v1".getBytes(StandardCharsets.UTF_8), 32));
deriveData returns raw PRK bytes; wrap them only to feed expandOnly. The same KDF instance can derive more than once — JEP 510 says additional deriveKey calls on one object are expected.
You can skip the PRK and call thenExpand twice with the same IKM and salt. That is correct and a bit more work for HKDF. Extract-once is the cleaner split when several keys share a context.
A TLS stack uses this same extract/expand shape in its key schedule. This post stops at checkout keys; you do not need to re-implement TLS to use the API.
Gotchas
Five things that will bite you the first week.
HKDF is not a password hasher. If the input is something a human typed, you are on the wrong API. Stay on SecretKeyFactory / PBKDF2 until the JDK grows Argon2 on KDF.
KDF methods are not thread-safe. Share the algorithm name, not one instance across requests, unless you synchronize. The HKDFParameterSpec.Builder is not thread-safe either.
Expand length is capped. RFC 5869 limits output to 255 * HashLen. For HKDF-SHA256 that is 8160 bytes. Asking for more fails; it is not a prompt to loop HMAC yourself.
deriveData can refuse. If the provider holds non-extractable key material (PKCS#11, HSM), deriveData throws UnsupportedOperationException. Prefer deriveKey and keep using the SecretKey handle. Delayed provider selection is why: getInstance may not pick the HSM until the first derive, so do not call getProviderName() before the first derive if you care which device ran.
Empty salt is legal and lazy. RFC 5869 allows a zero-length salt (it becomes a string of zeros). Generate a random salt and store it. Reusing one derived key for encryption and MAC is the other lazy failure — that is what info exists to prevent.
Out of scope
This API derives keys. It does not replace Cipher, Mac, KeyAgreement, or KEM.
- Do not roll AES or RSA internals.
Cipher.getInstance("AES/GCM/NoPadding")is the platform primitive;KDFonly feeds it a key. - Do not paste an HKDF into the repo “so we control it.” The JDK’s implementation is what TLS and HPKE in the platform will share.
- Do not treat this as a TLS tutorial. Hybrid TLS 1.3 and HPKE are why the JEP exists; your checkout secret is why you should call the same type.
Cheat sheet
Java 25 / JEP 510: final, no preview flag, in java.base
Java 24 / JEP 478: preview (--enable-preview); API otherwise the same
Types
javax.crypto.KDF
javax.crypto.spec.HKDFParameterSpec
javax.crypto.KDFSpi // providers only
Algorithms (SunJCE)
HKDF-SHA256 | HKDF-SHA384 | HKDF-SHA512
Entry
KDF hkdf = KDF.getInstance("HKDF-SHA256");
Extract-then-expand (default)
spec = HKDFParameterSpec.ofExtract()
.addIKM(ikm) // byte[] or SecretKey; chainable
.addSalt(salt)
.thenExpand(info, 32);
SecretKey aes = hkdf.deriveKey("AES", spec);
byte[] raw = hkdf.deriveData(spec);
Extract / expand split
extractOnly()
expandOnly(prk, info, length)
Not this API
PBKDF2 -> SecretKeyFactory
SHA-256 -> MessageDigest
new key -> KeyGenerator
Do:
- Use
KDF.getInstance("HKDF-SHA256")for high-entropy IKM (vault secrets, KEM output, processor keys). - Put a versioned purpose in
info(checkout:aes-gcm:v1) and change it when the key’s job changes. - Persist a random salt next to the ciphertext; it does not have to be secret.
- Call
deriveKeywhenCipherorMacneeds aSecretKey.
Don’t:
- HMAC-then-truncate, or copy an HKDF from StackOverflow.
- Run HKDF on a user password — that is PBKDF2’s job.
- Hash the secret with
MessageDigestand call the digest a key. - Derive the GCM IV; generate it with
SecureRandom. - Log IKM, PRK, or key bytes. Log the
infostring if you must log something.
Wrap-up
The KDF API is a maintenance fix for a class of bugs that hide in “temporary” crypto helpers. HMAC-then-truncate and a pasted HKDF both compile. Neither is the algorithm two implementations must match.
Java 25 finalizes javax.crypto.KDF (JEP 510) after a preview in 24. HKDF-SHA256 is the algorithm you call for checkout and payment secrets: extract a PRK from IKM and salt, expand with an info string, hand a SecretKey to Cipher or Mac. Leave passwords on SecretKeyFactory. Leave block ciphers on Cipher. Leave the KDF to the JDK.