Hybrid Post-Quantum TLS 1.3: Why ML-KEM Rides Alongside ECDHE
Somewhere, probably, a state-level adversary is recording your TLS traffic and doing nothing with it. Not because they can't, but because they don't need to yet. The plan is to decrypt it later, once a quantum computer exists that can break the ECDHE key exchange that protected it. This is the "harvest now, decrypt later" threat model, and it's the entire reason anyone bothers with post-quantum key exchange in TLS today, years before a machine capable of running Shor's algorithm against a 256-bit elliptic curve is known to exist. The recording is happening now. The decryption is a future problem, but the traffic being decrypted is today's.
NIST finalised ML-KEM in August 2024 as FIPS 203, a lattice-based key encapsulation mechanism derived from Kyber. The obvious move, if you're worried about quantum computers breaking ECDHE, is to rip ECDHE out of the TLS 1.3 key_share extension and put ML-KEM in its place. That is not what happened, and the reason why is more interesting than "caution".
What actually shipped
Instead of a straight swap, the IETF TLS working group defined a hybrid named group, X25519MLKEM768, registered as codepoint 4588 (0x11EC) in the TLS Supported Groups registry. A client offering this group sends both an X25519 key share and an ML-KEM-768 encapsulation key in the same key_share extension entry. The server responds with its own X25519 share and the ML-KEM ciphertext. Both sides now have two shared secrets, one from classical elliptic-curve Diffie-Hellman, one from a lattice-based KEM, and they get combined into a single value that feeds into the same HKDF-based key schedule TLS 1.3 already uses. No changes to the record layer, the handshake state machine, or the key schedule itself. From the protocol's point of view it's just a named group that happens to produce a bigger key share and a longer "shared secret".
The combination itself is deliberately boring: concatenate the two secrets and treat the result as the input keying material. The subtlety is in the order. You'd expect X25519MLKEM768 to concatenate X25519 first, then ML-KEM, matching the name. It's actually the other way round, ML-KEM's 32-byte secret first, X25519's second, for a 64-byte total. The reason is FIPS compliance: FIPS-approved cryptographic modules require the FIPS-approved algorithm's contribution to lead the concatenation, and ML-KEM has FIPS 203 behind it while raw X25519 ECDH doesn't carry the same standing in a module boundary. So the wire name and the byte order disagree, for compliance reasons that have nothing to do with security.
Why not just trust the new maths
The hybrid design is a hedge, and it's worth taking seriously why the hedge exists rather than treating it as boilerplate caution. Elliptic curve cryptography has had roughly four decades of sustained cryptanalytic attention. ML-KEM, as Kyber, has had a handful of years as a NIST competition finalist and a couple more since standardisation. That gap in scrutiny is not hypothetical: SIKE, another NIST PQC round finalist built on supersingular isogenies, looked solid right up until 2022, when the Castryck-Decru attack recovered private keys in about an hour on an ordinary laptop. It had survived years of the same competition process ML-KEM went through. Nobody wants to be the protocol that bet everything on the next SIKE.
Hybrid key exchange means an attacker has to break both the classical and the post-quantum component to recover the shared secret, not just one. If ML-KEM turns out to have a flaw nobody's found yet, ECDHE still holds against today's classical adversary (though not, eventually, against a quantum one). If a large quantum computer eventually arrives, ML-KEM still holds. It's belt and braces in the literal sense, and it's also policy: the NSA's CNSA 2.0 guidance for US national security systems explicitly recommends hybrid or dual-algorithm deployments through the transition period, rather than jumping straight to PQ-only.
The bit nobody mentions until their handshake times out
ECDHE with X25519 costs 32 bytes for a public key. ML-KEM-768 costs 1184 bytes for its encapsulation key and 1088 bytes for the ciphertext. A hybrid ClientHello is well over a kilobyte bigger than a classical one purely from the key share, and that's before you account for the server's response. For TCP this mostly doesn't matter. For QUIC, which runs over UDP and has to worry about fragmentation, amplification limits, and middleboxes that assume a handshake fits in one packet, it matters quite a lot. Go's own release notes point at tldr.fail, a site cataloguing TLS servers and middleboxes that silently break or time out once the handshake grows past what they expected. If you're debugging a mysterious handshake timeout against a specific server after enabling post-quantum key exchange, this is usually where the fault lies, not in your code.
Trying it in Go
Go 1.23 shipped an experimental hybrid group, X25519Kyber768Draft00, enabled by default ahead of the final standard. Go 1.24 removed it entirely and replaced it with the standardised X25519MLKEM768, also enabled by default, plus a new crypto/mlkem package implementing the primitive on its own. As of Go 1.24, Config.CurvePreferences no longer controls ordering, the runtime decides that, it only filters which groups are eligible:
package main
import (
"crypto/tls"
"fmt"
"log"
)
func main() {
cfg := &tls.Config{
ServerName: "example.com",
// Only used to select which groups are eligible; Go 1.24+
// decides the offer order itself.
CurvePreferences: []tls.CurveID{tls.X25519MLKEM768, tls.X25519},
}
conn, err := tls.Dial("tcp", "example.com:443", cfg)
if err != nil {
log.Fatal(err)
}
defer conn.Close()
state := conn.ConnectionState()
fmt.Printf("negotiated group: %v\n", state.CurveID)
}
ConnectionState.CurveID is a slightly odd name for a field that might report a lattice-based KEM rather than anything involving a curve, and the documentation says as much: it's called CurveID "for legacy reasons". If you need to disable ML-KEM against a server that mishandles the larger handshake, GODEBUG=tlsmlkem=0 reverts to classical-only groups without touching your code.
What this doesn't fix
All of this only touches key exchange, the ephemeral secret that protects the confidentiality of a connection. The certificate your server presents is still signed with ECDSA or RSA, both of which a sufficiently large quantum computer would break just as thoroughly as ECDHE. Nobody's in a hurry to hybridise certificate signatures the way key exchange has been hybridised, and the reason is the threat model doesn't match: harvest-now-decrypt-later works against recorded ciphertext, but forging a signature only helps an attacker at the moment they use it, against a quantum computer that would need to exist at attack time, not decades later. Post-quantum signatures like ML-DSA (FIPS 204) are coming, but the pressure behind them is different, and the PKI ecosystem around certificate sizes, CA compatibility, and CT log entries makes it a slower migration than swapping a key exchange group ever was.