Post-Quantum Cryptography in Java: ML-KEM, ML-DSA, Hybrid TLS 1.3 and What Got Faster in JDK 27
Runnable post-quantum cryptography in Java: ML-KEM and ML-DSA round trips with real key, ciphertext and signature sizes next to RSA and ECDSA, the hybrid X25519MLKEM768 TLS 1.3 handshake that JDK 27 enables by default, the failure you hit if you enable only a non-default hybrid group, and measured speed on JDK 25 versus 27. Three downloadable Java files.
Someone can record your encrypted traffic today and decrypt it years from now, when a large enough quantum computer exists. That is the “harvest now, decrypt later” threat, and it is the reason the JDK has been adding post-quantum cryptography release by release. JDK 24 added the algorithms (ML-KEM for key exchange and ML-DSA for signatures). JDK 27 added the piece most applications actually need: hybrid post-quantum key exchange in TLS 1.3, switched on by default.
This post runs all of it. Every output below is from a real run on JDK 27 (and, where noted, JDK 25). There is no companion repository; the three complete Java programs are in the download box further down.
Versions tested. Temurin 27+35 (GA 2026-09-15) and Temurin 25.0.4.1+1 (LTS). ML-KEM and ML-DSA arrived in JDK 24 (JEP 496 and JEP 497). Hybrid key exchange for TLS 1.3 is JEP 527, status “Closed / Delivered” for release 27 on openjdk.org. Timings come from a 2 vCPU, 8 GB sandbox and are indicative, not benchmarks.
The smallest correct mental model: two jobs, two algorithms
Public-key cryptography does two separate jobs, and quantum computers threaten both. The first job is agreeing on a secret key over an open network (key exchange). The second is proving who sent something (signatures). The new algorithms map one to one onto those jobs:
Job
Today
Post-quantum replacement
Java API
Key exchange
ECDH, X25519
ML-KEM (a KEM: key encapsulation mechanism)
KEM, KeyPairGenerator
Signatures
RSA, ECDSA, Ed25519
ML-DSA
Signature, KeyPairGenerator
A KEM works differently from Diffie-Hellman, and it is worth seeing the difference once. The receiver publishes a public key. The sender encapsulates: it produces a random shared secret and a ciphertext, and sends the ciphertext. The receiver decapsulates the ciphertext with its private key and gets the same secret. Nobody else can.
The diagram shows the hybrid handshake that JEP 527 adds. It does not replace X25519 with ML-KEM; it runs both and combines the results, so the connection stays safe as long as one of them is unbroken. That matters because ML-KEM is much younger than the elliptic-curve algorithms. The price is size: the client’s key share grows from 32 bytes to 1216, and the server’s reply is 1120. I confirmed both numbers by counting the bytes in the handshake dump from -Djavax.net.debug=ssl:handshake, and they equal 32 plus the standard ML-KEM-768 sizes of 1184 (public key) and 1088 (ciphertext).
ML-KEM: one round trip with the JDK’s own classes
ML-KEM comes in three strengths: 512, 768 and 1024. The JDK exposes them through the KEM class added in Java 21 plus an ordinary KeyPairGenerator. This is the core of PqcSizes.java:
== ML-KEM (JEP 496): key encapsulation ==
ML-KEM-512 publicKey= 822 B privateKey= 86 B ciphertext= 768 B sharedSecret=32 B secretsMatch=true
ML-KEM-768 publicKey=1206 B privateKey= 86 B ciphertext=1088 B sharedSecret=32 B secretsMatch=true
ML-KEM-1024 publicKey=1590 B privateKey= 86 B ciphertext=1568 B sharedSecret=32 B secretsMatch=true
Two details are worth noticing. The shared secret is always 32 bytes, whatever the strength, which is exactly what you need for an AES-256 key. And the encoded private key is only 86 bytes: that is far smaller than an expanded key, and is consistent with the encoding holding just a 64-byte seed plus a 22-byte wrapper (I did not decode it to confirm). The public key sizes here are the X.509-encoded form, so they are 22 bytes larger than the raw 800, 1184 and 1568 byte values in the standard.
ML-DSA: signatures, and what a tampered message does
ML-DSA also has three strengths, 44, 65 and 87, and plugs into the normal Signature API. The program signs a message, verifies it, then verifies the same signature against a changed message:
KeyPair kp = KeyPairGenerator.getInstance("ML-DSA-65").generateKeyPair();
Signature s = Signature.getInstance("ML-DSA-65");
s.initSign(kp.getPrivate()); s.update(msg); byte[] sig = s.sign();
Signature v = Signature.getInstance("ML-DSA-65");
v.initVerify(kp.getPublic()); v.update(msg);
boolean ok = v.verify(sig);
== ML-DSA (JEP 497): signatures ==
ML-DSA-44 publicKey=1334 B privateKey= 54 B signature=2420 B verifies=true tamperedVerifies=false
ML-DSA-65 publicKey=1974 B privateKey= 54 B signature=3309 B verifies=true tamperedVerifies=false
ML-DSA-87 publicKey=2614 B privateKey= 54 B signature=4627 B verifies=true tamperedVerifies=false
The last column is the point of a signature scheme: change one amount in the message (“2249.00” to “9999.00”) and the same signature no longer verifies.
The size bill: what post-quantum costs on the wire
Here are the classical algorithms from the same program, in the same units:
== Classical baselines for comparison ==
RSA-2048 publicKey= 294 B privateKey=1218 B signature= 256 B
RSA-3072 publicKey= 422 B privateKey=1795 B signature= 384 B
ECDSA P-256 publicKey= 91 B privateKey= 67 B signature~ 71 B (DER, varies)
Ed25519 publicKey= 44 B privateKey= 48 B signature= 64 B
X25519 key agreement: publicKey=44 B (this is what a classical TLS 1.3 client sends)
Put the two tables side by side and the trade-off is plain. An ML-DSA-65 signature is 3309 bytes against 256 for RSA-2048 (about 13 times larger) and 64 for Ed25519 (about 52 times larger). Its public key is 1974 bytes against 294 and 44. Signatures travel inside every certificate chain, so a post-quantum certificate chain is many kilobytes bigger than today’s. Key exchange is much cheaper to move: an ML-KEM-768 public key of 1184 raw bytes and ciphertext of 1088, paid once per handshake. That asymmetry is why hybrid key exchange arrived in TLS first and post-quantum certificates have not.
Hybrid TLS 1.3: what JDK 27 does without being asked
TLS clients list the key exchange schemes they support (“named groups”) in order of preference. JEP 527 puts X25519MLKEM768 first by default in JDK 27, so an unchanged application that uses javax.net.ssl starts offering a post-quantum hybrid. HybridTls.java runs a TLS 1.3 server and client over loopback in one JVM and the handshake debug output shows exactly what each side offers. First create a throwaway certificate and run the program with the filter shown:
Read it in order. The first line is the client’s preference list, with the hybrid at the front. The next two lines are the key shares the client actually sent: the hybrid and plain x25519. After the server’s copy of the list and its view of those two shares, the remaining named group lines are the server’s answer: it picked X25519MLKEM768, in both its own and the client’s record of the ServerHello. The handshake then reports TLS 1.3. No code changed; the JDK did this by default.
The same program on JDK 25 shows what you have today:
JDK 25 has the ML-KEM and ML-DSA classes (the earlier sections ran on it unchanged) but its TLS stack does not use them. Notice a second difference that is easy to miss: JDK 27’s default list no longer contains ffdhe6144 or ffdhe8192. If you depend on those large finite-field groups, set jdk.tls.namedGroups yourself.
How to turn it off, and the trap when you turn on a different group
To keep the old behaviour, set the system property named in the JEP. This is the same JDK 27 binary with the hybrid removed:
Now the trap. JEP 527 adds three hybrid groups but enables only X25519MLKEM768 by default. If you configure a client to offer only one of the others, a server on defaults will refuse it:
== JDK 27, client offers only SecP256r1MLKEM768
"named groups": [SecP256r1MLKEM768]
"named group": SecP256r1MLKEM768
"named groups": [SecP256r1MLKEM768]
"named group": SecP256r1MLKEM768
javax.net.ssl.SSLProtocolException: (unexpected_message) No common named group
javax.net.ssl.SSLProtocolException: (unexpected_message) Received fatal alert: unexpected_message
server side: javax.net.ssl.SSLProtocolException: (unexpected_message) No common named group
The message to remember is No common named group. It is a configuration mismatch, not a bug, and the fatal alert says unexpected_message, which does not point at the cause. Enabling the group on both sides works:
In production code you select groups with SSLParameters.setNamedGroups, which is what HybridTls.java does with its optional arguments.
What got faster: the JDK 27 numbers
Oracle’s JDK 27 announcement mentions “improved cryptographic performance” but, as far as I could tell, gives no detail, so I measured it. PqcSpeed.java times each operation in a loop, single thread, 1.5 seconds of warmup and 2 seconds measured. I ran it three times on each JDK, interleaved. This is the median of three runs, with ratios (JDK 27 divided by JDK 25):
Operation
JDK 25 ops/s
JDK 27 ops/s
Ratio
ML-KEM-768 key generation
30,931
37,133
1.20
ML-KEM-768 encapsulate
35,310
44,670
1.27
ML-KEM-768 decapsulate
29,305
37,171
1.27
ML-DSA-65 key generation
6,023
7,721
1.28
ML-DSA-65 sign
3,267
4,089
1.25
ML-DSA-65 verify
8,284
10,907
1.32
X25519 key generation + agreement
6,438
9,500
1.48
RSA-2048 sign
1,028
1,052
1.02
RSA-2048 verify
26,545
27,747
1.05
ECDSA P-256 sign
6,286
6,450
1.03
ECDSA P-256 verify
1,941
1,954
1.01
Two things stand out. The post-quantum operations and X25519 are 20% to 48% faster on JDK 27, and in each of those rows the three JDK 27 runs are all faster than the three JDK 25 runs (no overlap). RSA and ECDSA barely moved, within a few percent. So the speed-up is real for the algorithms the hybrid handshake uses, which is the useful direction. What I did not establish is the mechanism: I did not inspect the JIT or the JDK source, so I cannot say whether these are new intrinsics or something else. Treat the cause as unknown.
Going deeper: one complete raw run on each JDK JDK 27, first run, as printed by PqcSpeed.java:
The harness is a simple timed loop, not JMH, and it creates a new Signature object per operation, so absolute numbers include that overhead. The ratio between JDKs is the useful part. Run-to-run spread is visible in the raw files: for example, ECDSA sign ranged from 6,221 to 7,355 ops/s on JDK 25 across three runs.
What to do now
The practical position for most applications. Key exchange is the urgent part, because of “harvest now, decrypt later”, and on JDK 27 you get a hybrid group by default with no code change. Signatures and certificates are not urgent in the same way: an attacker cannot go back and forge a signature on a session that has already ended, and post-quantum certificate chains are far larger. So the work is mostly (1) move to a JDK that has JEP 527 where you can, (2) check that your load balancers, proxies and servers tolerate a ClientHello that is over 1 KB larger, and (3) test what happens with middleboxes that choke on it.
What about the LTS releases? JDK 25 LTS already contains ML-KEM and ML-DSA (my programs ran on it) but not hybrid TLS: its default group list is the classical one shown above. I could not confirm any backport. Oracle’s JDK 27 announcement says it is working on a roadmap to bring comparable capabilities to LTS releases, without naming versions. If you must stay on an LTS, plan on application-level KEM use or a vendor build rather than assuming a backport is coming.
Download the code.PqcSizes.java (ML-KEM and ML-DSA round trips and sizes), PqcSpeed.java (operations per second) and HybridTls.java (TLS 1.3 named groups over loopback). Each compiles with a plain javac -d out File.java on JDK 24 or later and runs with java -cp out ClassName. HybridTls needs the tls.p12 keystore from the keytool command above.
No Comments yet!