Every HTTPS connection starts with a key exchange, and today that exchange relies on math that a large quantum computer could break. No such computer exists yet, but traffic recorded today can be decrypted once one does. ML-KEM is the standard replacement, and Java 25 can already use it in our own code. The JDK’s TLS stack in Java 25, however, still uses the old key exchange. Hybrid key exchange in TLS arrives only in JDK 27.
In short: use the ML-KEM API on Java 25 when our own code exchanges keys, and move to JDK 27 to get post-quantum key exchange in TLS without code changes.
In this tutorial, we’ll first see why today’s key exchange is at risk and how ML-KEM works. Then we’ll use it in Java 25 to share a secret and encrypt a message. After that, we’ll see why Java 25’s TLS doesn’t use ML-KEM, and how JDK 27’s hybrid key exchange fixes that. Every claim comes with a test or real output.
1. Why Today’s TLS Key Exchange Is at Risk
When a Java client opens an HTTPS connection, client and server first agree on a secret session key, and everything after that is encrypted with it. In TLS 1.3, the current version of the protocol, this agreement uses elliptic-curve Diffie-Hellman, ECDHE for short. Each side has a key pair, combines its own private key with the other side’s public key, and both arrive at the same secret. The most common curve for it is x25519.
The security of ECDHE rests on a math problem that ordinary computers can’t solve in any useful time. A large enough quantum computer could solve it. Such a computer doesn’t exist yet, and nobody knows when it will.
The problem is that it doesn’t have to exist today to hurt us. An attacker can record encrypted traffic now, store it, and decrypt it years later. The JEP 527 announcement calls this “harvest now, decrypt later”. For data that must stay secret for years, such as medical or financial records, the risk already exists.
That’s why key exchange is the first part of TLS to replace. NIST, the US standards institute, published the replacement in August 2024 as FIPS 203: ML-KEM, the Module-Lattice-based Key Encapsulation Mechanism. Before we use it, let’s see what a key encapsulation mechanism is, because it works differently from Diffie-Hellman.
2. How a Key Encapsulation Mechanism Works
With Diffie-Hellman, both sides contribute to the secret and compute it together. A key encapsulation mechanism, or KEM, works more like sending a locked box:
- The receiver creates a key pair and publishes the public key.
- The sender uses that public key to create a fresh random secret, and at the same time an encapsulation, a byte array that only the matching private key can open.
- The sender keeps the secret and sends the encapsulation to the receiver.
- The receiver opens the encapsulation with its private key and gets the same secret.
The secret itself never travels over the network, only the encapsulation does. Both sides end up with the same secret, exactly as with Diffie-Hellman, and can use it as a key for normal encryption.
Java added the pieces for this over several releases:
| Release | Change |
|---|---|
| Java 21 | The javax.crypto.KEM API for any KEM algorithm (JEP 452) |
| Java 24 | ML-KEM in KeyPairGenerator, KEM and KeyFactory (JEP 496) |
| Java 25 | The first long-term support (LTS) release that includes ML-KEM |
| JDK 27 | Hybrid ML-KEM key exchange in TLS 1.3, on by default (JEP 527) |
So on Java 25, we have everything we need to use ML-KEM in our own code. Each snippet in the next sections is the body of a JUnit 5 test with AssertJ assertions. The pom.xml and the full test classes, with their imports, are in the source code linked at the end.
3. Sharing a Secret With ML-KEM in Java 25
Let’s walk through the four steps of section 2 in Java, starting with the receiver.
3.1. Generating the Receiver’s Key Pair
The receiver creates its key pair with the same KeyPairGenerator API that we use for RSA or EC keys:
KeyPair receiver = KeyPairGenerator.getInstance("ML-KEM").generateKeyPair();
NamedParameterSpec params = (NamedParameterSpec) ((AsymmetricKey) receiver.getPublic()).getParams();
assertThat(receiver.getPublic().getAlgorithm()).isEqualTo("ML-KEM");
assertThat(params.getName()).isEqualTo("ML-KEM-768");
We asked for “ML-KEM” and got a key of the ML-KEM-768 parameter set. A parameter set fixes the security level and the sizes of the keys. AsymmetricKey.getParams() tells us which one a key belongs to.
3.2. Choosing an ML-KEM Parameter Set
FIPS 203 defines three parameter sets: ML-KEM-512, ML-KEM-768 and ML-KEM-1024. A higher number means a higher security level and larger keys. To see how much larger, let’s request each set by name and print the sizes:
for (String set : new String[] {"ML-KEM-512", "ML-KEM-768", "ML-KEM-1024"}) {
KeyPair keys = KeyPairGenerator.getInstance(set).generateKeyPair();
KEM.Encapsulator encapsulator = KEM.getInstance("ML-KEM").newEncapsulator(keys.getPublic());
System.out.printf("%s public key %d bytes, encapsulation %d bytes, secret %d bytes%n",
set, keys.getPublic().getEncoded().length, encapsulator.encapsulationSize(), encapsulator.secretSize());
}
The output on Java 25 is:
ML-KEM-512 public key 822 bytes, encapsulation 768 bytes, secret 32 bytes
ML-KEM-768 public key 1206 bytes, encapsulation 1088 bytes, secret 32 bytes
ML-KEM-1024 public key 1590 bytes, encapsulation 1568 bytes, secret 32 bytes
Two things stand out. First, the shared secret is always 32 bytes, exactly the size of an AES-256 key, so we can use it directly for encryption. Second, the keys are big: an encoded x25519 public key is only 44 bytes, while an ML-KEM-768 public key is 1,206 bytes. Post-quantum key exchange costs more bytes on the wire. ML-KEM-768 is a sensible middle ground, and it’s also the set that TLS uses by default in JDK 27, as we’ll see in section 7.
3.3. Encapsulating and Decapsulating the Secret
Now the sender takes the receiver’s public key, creates an Encapsulator, and calls encapsulate(). The receiver creates a Decapsulator with its private key and opens the encapsulation:
KeyPair receiver = KeyPairGenerator.getInstance("ML-KEM").generateKeyPair();
KEM kem = KEM.getInstance("ML-KEM");
KEM.Encapsulated encapsulated = kem.newEncapsulator(receiver.getPublic()).encapsulate();
byte[] message = encapsulated.encapsulation();
SecretKey receiverSecret = kem.newDecapsulator(receiver.getPrivate()).decapsulate(message);
assertThat(message).hasSize(1088);
assertThat(receiverSecret.getEncoded()).isEqualTo(encapsulated.key().getEncoded()).hasSize(32);
This is the locked box from section 2. The Encapsulated object holds the sender’s copy of the secret in key() and the box in encapsulation(). Only the 1,088-byte encapsulation is sent. The last assertion confirms that the receiver’s secret is byte for byte the same as the sender’s.
3.4. Sending the Public Key as Bytes
In a real system, the receiver’s public key also has to reach the sender over the network. getEncoded() turns it into bytes in the X.509 format, the standard binary layout that certificates also use for public keys. On the other side, KeyFactory turns those bytes back into a key:
byte[] encoded = receiver.getPublic().getEncoded();
PublicKey decoded = KeyFactory.getInstance("ML-KEM").generatePublic(new X509EncodedKeySpec(encoded));
assertThat(receiver.getPublic().getFormat()).isEqualTo("X.509");
assertThat(decoded).isEqualTo(receiver.getPublic());
The private key works the same way with PKCS8EncodedKeySpec, for the PKCS #8 format that is standard for private keys.
4. Encrypting a Message With the Shared Secret
Both sides now share a 32-byte secret, but a KEM only agrees on a key. It doesn’t encrypt anything. To protect a message, we use the secret as an AES key. Let’s bundle what the receiver needs in a small record:
public record SealedMessage(byte[] encapsulation, byte[] iv, byte[] ciphertext) { }
The sender asks the encapsulator for the secret directly as an AES key, and encrypts with AES-GCM. RANDOM is a SecureRandom field of our MessageSealer class:
public SealedMessage seal(PublicKey receiverKey, String message) throws GeneralSecurityException {
KEM.Encapsulated encapsulated = KEM.getInstance("ML-KEM")
.newEncapsulator(receiverKey)
.encapsulate(0, 32, "AES");
byte[] iv = new byte[12];
RANDOM.nextBytes(iv);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, encapsulated.key(), new GCMParameterSpec(128, iv));
byte[] ciphertext = cipher.doFinal(message.getBytes(StandardCharsets.UTF_8));
return new SealedMessage(encapsulated.encapsulation(), iv, ciphertext);
}
The call encapsulate(0, 32, “AES”) returns bytes 0 up to, but not including, 32 of the secret as a SecretKey labeled “AES”. This detail matters: the plain encapsulate() labels the key “Generic”, and Cipher.init() rejects it with “InvalidKeyException: Wrong algorithm: AES or Rijndael required”.
The receiver does the same steps in reverse. It opens the encapsulation to get the AES key, then decrypts:
public String open(PrivateKey receiverKey, SealedMessage sealed) throws GeneralSecurityException {
SecretKey aesKey = KEM.getInstance("ML-KEM")
.newDecapsulator(receiverKey)
.decapsulate(sealed.encapsulation(), 0, 32, "AES");
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, aesKey, new GCMParameterSpec(128, sealed.iv()));
return new String(cipher.doFinal(sealed.ciphertext()), StandardCharsets.UTF_8);
}
Let’s check that a message survives the round trip:
MessageSealer sealer = new MessageSealer();
SealedMessage sealed = sealer.seal(receiver.getPublic(), "Invoice 4711 is paid");
assertThat(sealer.open(receiver.getPrivate(), sealed)).isEqualTo("Invoice 4711 is paid");
This is the full post-quantum pattern: ML-KEM agrees on the key, and AES encrypts the data. AES-GCM itself, including why the IV must never repeat for a key, is covered in Java AES Encryption and Decryption.
5. The Catches When Using ML-KEM in Java
The ML-KEM API looks like the key APIs we already know, but a few of its rules differ. Each of the following mistakes compiles fine and fails only at runtime, or doesn’t fail when we expect it to.
5.1. Passing a Key Size Fails
With RSA or EC, we often call initialize() with a key size. ML-KEM has no key sizes, only the three named parameter sets, so the old habit throws:
KeyPairGenerator generator = KeyPairGenerator.getInstance("ML-KEM");
assertThatThrownBy(() -> generator.initialize(768))
.isInstanceOf(InvalidParameterException.class)
.hasMessage("keysize not supported");
The fix is to name the set, either in getInstance(“ML-KEM-768”) or with initialize(NamedParameterSpec.ML_KEM_768).
5.2. A Parameter-Set Name Rejects Keys of Other Sets
When we create a KEM with a specific set name instead of the family name, it accepts only keys of that set:
KeyPair receiver = KeyPairGenerator.getInstance("ML-KEM-768").generateKeyPair();
assertThatThrownBy(() -> KEM.getInstance("ML-KEM-512").newEncapsulator(receiver.getPublic()))
.isInstanceOf(InvalidKeyException.class)
.hasMessage("Unsupported parameter set name: ML-KEM-768");
KEM.getInstance(“ML-KEM”) accepts all three sets, so we should use the family name unless we want to reject the others on purpose.
5.3. A Tampered Encapsulation Does Not Throw
This one is the most surprising. If someone flips a bit in the encapsulation on its way to the receiver, we might expect decapsulate() to throw. It doesn’t:
KEM.Encapsulated encapsulated = kem.newEncapsulator(receiver.getPublic()).encapsulate();
byte[] tampered = encapsulated.encapsulation().clone();
tampered[0] ^= 1;
SecretKey wrong = kem.newDecapsulator(receiver.getPrivate()).decapsulate(tampered);
assertThat(wrong.getEncoded()).hasSize(32).isNotEqualTo(encapsulated.key().getEncoded());
Here, receiver and kem are created as in section 3.3. When an encapsulation is modified but keeps its length, ML-KEM silently returns a different secret instead of throwing. FIPS 203 requires this behavior and calls it implicit rejection. It’s deliberate: an error would tell an attacker which modifications the receiver noticed, and that information can help break the scheme.
So a successful decapsulate() proves nothing about where the encapsulation came from. Our MessageSealer is still safe, because AES-GCM is an authenticated cipher. It adds a tag, a short checksum computed with the key, and decryption fails when the tag doesn’t match. With a wrong key, it can’t match:
SealedMessage sealed = sealer.seal(receiver.getPublic(), "Invoice 4711 is paid");
byte[] tampered = sealed.encapsulation().clone();
tampered[0] ^= 1;
SealedMessage attacked = new SealedMessage(tampered, sealed.iv(), sealed.ciphertext());
assertThatThrownBy(() -> sealer.open(receiver.getPrivate(), attacked))
.isInstanceOf(AEADBadTagException.class)
.hasMessage("Tag mismatch");
The lesson is to always pair ML-KEM with an authenticated cipher like AES-GCM. Only an encapsulation of the wrong length makes decapsulate() throw, with a DecapsulateException and the message “Invalid key encapsulation message length”.
5.4. Java 21 Has No ML-KEM
Java 21 already has the KEM API, so code that uses it compiles there. The ML-KEM algorithm, however, arrived only in Java 24, so on Java 21 the first line fails at runtime:
java.security.NoSuchAlgorithmException: ML-KEM KeyPairGenerator not available
6. Why Java 25 TLS Is Still Not Quantum-Safe
We now have ML-KEM working in Java 25, so the obvious question is whether Java 25’s own TLS stack uses it for HTTPS. It doesn’t. JEP 496, which added ML-KEM in Java 24, lists TLS support as a non-goal. Let’s prove it with a real handshake.
6.1. Setting Up a Local TLS Server and Client
To test handshakes, we run a TLS server and a client in the same JVM. Both trust one self-signed EC certificate, which we create with keytool:
keytool -genkeypair -alias server -keyalg EC -groupname secp256r1 -dname CN=localhost \
-validity 30 -storetype PKCS12 -keystore server.p12 -storepass changeit
In TLS, the algorithm the two sides use for key exchange is called a named group; x25519 is one. Each side has a list of groups it supports, and the handshake picks one that both lists contain. Our LocalTls helper loads the keystore into an SSLContext and runs one handshake per call. Its handshake(serverGroups, clientGroups) method sets the named groups of each side, and null keeps the JDK defaults. Setting the groups of a socket takes three lines, shown here for the server:
SSLParameters params = server.getSSLParameters();
params.setNamedGroups(serverGroups);
server.setSSLParameters(params);
The client socket uses the same three lines. In the tests below, tls is a LocalTls instance; the full helper is in the source code.
6.2. Restricting a Java 25 Server to the Post-Quantum Group
Now let’s tell a Java 25 server to accept only X25519MLKEM768, the post-quantum group that JDK 27 adds. If Java 25 supported it, the handshake would succeed:
assertThatThrownBy(() -> tls.handshake(new String[] {"X25519MLKEM768"}, null))
.isInstanceOf(SSLProtocolException.class);
The handshake fails. setNamedGroups() accepts the name without complaint, but the JDK doesn’t know the group, so the server has nothing left to offer. The server side reports “No common named group”, and the client receives an unexpected_message alert. The default list of Java 25 starts with x25519 and contains no post-quantum group. Every HTTPS connection from Java 25 is therefore still exposed to “harvest now, decrypt later”.
7. How JDK 27 Hybrid Key Exchange Fixes TLS
JDK 27, generally available since September 15, 2026, brings post-quantum key exchange into TLS 1.3 with JEP 527. It doesn’t simply replace x25519 with ML-KEM, though.
7.1. Why the New Groups Are Hybrid
JEP 527 points out that ML-KEM hasn’t yet had the years of analysis that the classical algorithms have. If a flaw were found in ML-KEM, switching to it alone would leave TLS weaker than it is today. So TLS uses hybrid groups: a hybrid group runs x25519 and ML-KEM-768 side by side and derives the session key from both secrets. An attacker then has to break both algorithms. A quantum computer would break x25519 but not ML-KEM, and a flaw in ML-KEM wouldn’t help against x25519.
JDK 27 adds three hybrid groups: X25519MLKEM768, SecP256r1MLKEM768 and SecP384r1MLKEM1024. They follow RFC 9954, which describes hybrid key exchange in TLS 1.3, and RFC 10024, which defines these groups. They work only with TLS 1.3. We tested the examples in this section on Amazon Corretto 27.0.1, a build of JDK 27.
7.2. The Hybrid Group Is the New Default
On JDK 27, X25519MLKEM768 is the first group that a TLS client offers:
List<String> groups = List.of(tls.context().getDefaultSSLParameters().getNamedGroups());
assertThat(groups).first().isEqualTo("X25519MLKEM768");
assertThat(groups).contains("x25519", "secp256r1");
This means existing code that uses the JDK’s TLS stack in javax.net.ssl, including HttpClient and HttpsURLConnection, offers hybrid key exchange after the JDK upgrade, with no code change. The classical groups stay in the list, so the client can still connect to servers that don’t support ML-KEM.
7.3. Proving That a Handshake Uses the Hybrid Group
SSLSession has no method that returns the negotiated group, so we can’t simply ask. Instead, let’s repeat the experiment from section 6.2 on JDK 27: a server restricted to the hybrid group, and a client with default settings:
String protocol = tls.handshake(new String[] {"X25519MLKEM768"}, null);
assertThat(protocol).isEqualTo("TLSv1.3");
The same handshake that failed on Java 25 now succeeds, and it can only succeed with X25519MLKEM768.
For an existing application, we can check the handshake with handshake logging, here for an application packaged as app.jar:
java -Djavax.net.debug=ssl:handshake -jar app.jar
To read the log, it helps to know the first two messages of a TLS 1.3 handshake. The client starts with a ClientHello, which carries key shares: public key-exchange values for the groups the client guesses the server will accept. On JDK 27, it sends two, one hybrid and one classical. The server answers with a ServerHello, which names the group it picked and the cipher suite, the symmetric algorithms for the session. Here is the trimmed ServerHello from a run with default settings on both sides:
"ServerHello": {
"cipher suite" : "TLS_AES_256_GCM_SHA384(0x1302)",
...
"key_share (51)": {
"server_share": {
"named group": X25519MLKEM768
7.4. Choosing the Named Groups
Sometimes we need to control the groups, for example to test a server that doesn’t support the hybrid groups yet. For the whole JVM, we set the jdk.tls.namedGroups system property, and the JDK’s security settings report the result:
java -Djdk.tls.namedGroups=x25519,secp256r1 -XshowSettings:security:tls -version
On JDK 27, the output lists only the two groups we chose. Java 25 prints no named-groups section for this option at all:
Enabled Named Groups:
x25519
secp256r1
For a single connection, we call setNamedGroups() on the socket’s SSLParameters, as the LocalTls helper does. When the client offers only a classical group, the handshake falls back to it:
String protocol = tls.handshake(null, new String[] {"x25519"});
assertThat(protocol).isEqualTo("TLSv1.3");
If the two sides have no group in common, the handshake fails with an SSLProtocolException, and the server logs “No common named group”, as we saw in section 6.2.
8. What Hybrid Key Exchange Does Not Protect Yet
Hybrid key exchange protects the session keys of TLS 1.3 connections made by the JDK’s own TLS stack, and that covers the “harvest now, decrypt later” threat for those connections. Three things remain classical:
- Certificates and signatures, so authentication isn’t quantum-safe yet. Java 24 added the ML-DSA signature algorithm (JEP 497), but TLS doesn’t use it.
- TLS 1.2 connections, because the hybrid groups work only with TLS 1.3.
- Libraries that bring their own TLS implementation instead of the JDK’s.
All JDK 27 changes are listed in Java 27 Features, and the Java 25 baseline is in Java 25 Features.
9. What Should We Do Now?
Putting it all together, the choices follow from where the key exchange happens:
- For key exchange in our own code, such as encrypting a message for a known receiver, we can use ML-KEM on Java 25 today. We should always pair it with an authenticated cipher like AES-GCM.
- For HTTPS and other TLS connections, the JDK has to do the work. On Java 25, connections still use classical key exchange, and no setting changes that. Upgrading to JDK 27 makes the hybrid group the default for TLS 1.3, with no code change.
- When a peer doesn’t support the hybrid groups, JDK 27 falls back to the classical groups by itself. We need jdk.tls.namedGroups or setNamedGroups() only for testing or for strict policies.
10. Conclusion
In this article, we saw why recorded traffic makes today’s key exchange a risk, and how a key encapsulation mechanism like ML-KEM replaces it. On Java 25, we shared a secret with the KEM API and encrypted a message with it, keeping in mind that a tampered encapsulation of the correct length doesn’t throw. Java 25’s TLS stack doesn’t use ML-KEM, while JDK 27 offers the hybrid X25519MLKEM768 group first and negotiates it with a hybrid-only server.