Skip to main content

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:
JobTodayPost-quantum replacementJava API
Key exchangeECDH, X25519ML-KEM (a KEM: key encapsulation mechanism)KEM, KeyPairGenerator
SignaturesRSA, ECDSA, Ed25519ML-DSASignature, 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.
Client Server key share: x25519 (32 B) + ML-KEM-768 public key = 1216 B key share: x25519 (32 B) + ML-KEM ciphertext = 1120 B encapsulation keydecapsulates encapsulatesto the public key secret = x25519 secret + ML-KEM secret, combined Secure if either algorithm holds. Cost: about 1.2 KB each way instead of 32 bytes.
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:
KeyPair kp = KeyPairGenerator.getInstance("ML-KEM-768").generateKeyPair();
KEM kem = KEM.getInstance("ML-KEM");
KEM.Encapsulated enc = kem.newEncapsulator(kp.getPublic()).encapsulate();
SecretKey viaDecap = kem.newDecapsulator(kp.getPrivate()).decapsulate(enc.encapsulation());
boolean same = MessageDigest.isEqual(enc.key().getEncoded(), viaDecap.getEncoded());
== 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:
$ keytool -genkeypair -keyalg EC -groupname secp256r1 -alias srv -dname "CN=localhost" -validity 30 \
    -storetype PKCS12 -keystore tls.p12 -storepass changeit -keypass changeit -ext san=dns:localhost
$ java -Djavax.net.debug=ssl:handshake -cp out HybridTls tls.p12 2>&1 \
    | grep -E '"named groups?"|protocol=|java.version=|server side|SSLProtocolException'
On JDK 27 with defaults:
== JDK 27 defaults
"named groups": [X25519MLKEM768, x25519, secp256r1, secp384r1, secp521r1, x448, ffdhe2048, ffdhe3072, ffdhe4096]
"named group": X25519MLKEM768
"named group": x25519
"named groups": [X25519MLKEM768, x25519, secp256r1, secp384r1, secp521r1, x448, ffdhe2048, ffdhe3072, ffdhe4096]
"named group": X25519MLKEM768
"named group": x25519
"named group": X25519MLKEM768
"named group": X25519MLKEM768
"named groups": [X25519MLKEM768, x25519, secp256r1, secp384r1, secp521r1, x448, ffdhe2048, ffdhe3072, ffdhe4096]
"named groups": [X25519MLKEM768, x25519, secp256r1, secp384r1, secp521r1, x448, ffdhe2048, ffdhe3072, ffdhe4096]
java.version=27
protocol=TLSv1.3 cipher=TLS_AES_256_GCM_SHA384
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 defaults
"named groups": [x25519, secp256r1, secp384r1, secp521r1, x448, ffdhe2048, ffdhe3072, ffdhe4096, ffdhe6144, ffdhe8192]
"named group": x25519
"named group": secp256r1
"named groups": [x25519, secp256r1, secp384r1, secp521r1, x448, ffdhe2048, ffdhe3072, ffdhe4096, ffdhe6144, ffdhe8192]
"named group": x25519
"named group": secp256r1
"named group": x25519
"named group": x25519
"named groups": [x25519, secp256r1, secp384r1, secp521r1, x448, ffdhe2048, ffdhe3072, ffdhe4096, ffdhe6144, ffdhe8192]
"named groups": [x25519, secp256r1, secp384r1, secp521r1, x448, ffdhe2048, ffdhe3072, ffdhe4096, ffdhe6144, ffdhe8192]
java.version=25.0.4.1
protocol=TLSv1.3 cipher=TLS_AES_256_GCM_SHA384
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:
== JDK 27 with -Djdk.tls.namedGroups=x25519,secp256r1
"named groups": [x25519, secp256r1]
"named group": x25519
"named group": secp256r1
"named groups": [x25519, secp256r1]
"named group": x25519
"named group": secp256r1
"named group": x25519
"named group": x25519
"named groups": [x25519, secp256r1]
"named groups": [x25519, secp256r1]
java.version=27
protocol=TLSv1.3 cipher=TLS_AES_256_GCM_SHA384
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:
== JDK 27, client and server both enable SecP256r1MLKEM768
"named groups": [SecP256r1MLKEM768]
"named group": SecP256r1MLKEM768
"named groups": [SecP256r1MLKEM768]
"named group": SecP256r1MLKEM768
"named group": SecP256r1MLKEM768
"named group": SecP256r1MLKEM768
"named groups": [SecP256r1MLKEM768]
"named groups": [SecP256r1MLKEM768]
java.version=27
protocol=TLSv1.3 cipher=TLS_AES_256_GCM_SHA384
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):
OperationJDK 25 ops/sJDK 27 ops/sRatio
ML-KEM-768 key generation30,93137,1331.20
ML-KEM-768 encapsulate35,31044,6701.27
ML-KEM-768 decapsulate29,30537,1711.27
ML-DSA-65 key generation6,0237,7211.28
ML-DSA-65 sign3,2674,0891.25
ML-DSA-65 verify8,28410,9071.32
X25519 key generation + agreement6,4389,5001.48
RSA-2048 sign1,0281,0521.02
RSA-2048 verify26,54527,7471.05
ECDSA P-256 sign6,2866,4501.03
ECDSA P-256 verify1,9411,9541.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:
java.version=27
ML-KEM-768 keygen                       37133 ops/s
ML-KEM-768 encapsulate                  45488 ops/s
ML-KEM-768 decapsulate                  37171 ops/s
ML-DSA-65 keygen                         8282 ops/s
ML-DSA-65 sign                           3959 ops/s
ML-DSA-65 verify                        10990 ops/s
RSA-2048 sign                            1019 ops/s
RSA-2048 verify                         28823 ops/s
ECDSA P-256 sign                         6450 ops/s
ECDSA P-256 verify                       1948 ops/s
X25519 keygen + agreement                9281 ops/s
JDK 25, first run:
java.version=25.0.4.1
ML-KEM-768 keygen                       30467 ops/s
ML-KEM-768 encapsulate                  35310 ops/s
ML-KEM-768 decapsulate                  29305 ops/s
ML-DSA-65 keygen                         5983 ops/s
ML-DSA-65 sign                           3293 ops/s
ML-DSA-65 verify                         8284 ops/s
RSA-2048 sign                            1112 ops/s
RSA-2048 verify                         25871 ops/s
ECDSA P-256 sign                         7355 ops/s
ECDSA P-256 verify                       2000 ops/s
X25519 keygen + agreement                6537 ops/s
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.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.