Every HTTPS connection starts with a key exchange. For the past decade that has almost always been X25519, an elliptic-curve Diffie–Hellman. A large quantum computer running Shor’s algorithm would break it, and an attacker who records traffic today could decrypt it then. Session keys don’t expire in a way that helps you: if the key exchange falls, every recorded byte protected by it falls with it.

Hybrid TLS is the migration step the industry settled on. Instead of replacing X25519, the client and server run it together with ML-KEM-768, the lattice-based KEM from FIPS 203, and derive the session keys from both results. This page explains how that works on the wire and what you need to do to enable and verify it.

Why hybrid instead of pure ML-KEM?

Two threats pull in opposite directions. A quantum adversary breaks X25519 but not ML-KEM. A classical cryptanalytic surprise, or an implementation bug, could break the much younger ML-KEM while leaving X25519 untouched. Combining the two means an attacker has to break both.

Harvest now, decrypt later

Encrypted traffic recorded today can be decrypted whenever the key exchange is broken. Confidentiality that must last ten years needs post-quantum key exchange now, long before anyone has a cryptographically relevant quantum computer.

Hedging against ML-KEM

ML-KEM rests on Module-LWE, which has held up well, but it has far less deployment history than elliptic curves. Timing bugs such as KyberSlash have already hit implementations. Keeping X25519 in the mix means a broken ML-KEM leaves you no worse off than today.

Authentication can wait

Hybrid key exchange fixes confidentiality only. Certificates still use RSA or ECDSA, which is fine for now: a signature only has to resist forgery at the moment of the handshake, so it cannot be “harvested”. Post-quantum certificates (ML-DSA) are a separate, later migration.

How the combiner works in TLS 1.3

TLS 1.3 negotiates key exchange through named groups. The hybrid design (RFC 9954, “Hybrid Key Exchange in TLS 1.3”) keeps the protocol unchanged: a hybrid is just one more group, whose key share is two key shares glued together. RFC 10024 defines three such groups:

GroupCodepointClient shareServer shareOrder
X25519MLKEM7680x11EC (4588)1,216 bytes1,120 bytesML-KEM first
SecP256r1MLKEM7680x11EB (4587)1,249 bytes1,153 bytesECDHE first
SecP384r1MLKEM10240x11ED (4589)1,665 bytes1,665 bytesECDHE first

For X25519MLKEM768 the handshake runs like this:

  1. ClientHello. The client generates an ML-KEM-768 key pair and an X25519 key pair and sends the 1,184-byte encapsulation key followed by the 32-byte X25519 public key: 1,216 bytes in one key_share entry.
  2. ServerHello. The server checks the encapsulation key (the FIPS 203 input check is mandatory), encapsulates to it and runs X25519. It replies with the 1,088-byte ML-KEM ciphertext followed by its 32-byte X25519 public key: 1,120 bytes.
  3. Key schedule. Both sides concatenate the two 32-byte shared secrets, ML-KEM first, and use the 64-byte result exactly where TLS 1.3 would use a plain (EC)DHE secret.
shared_secret=ssML-KEM ∥ ssX25519(64 bytes)  ⟶  HKDF-Extract\text{shared\_secret} = ss_{\text{ML-KEM}} \,\|\, ss_{\text{X25519}} \quad (64 \text{ bytes}) \;\longrightarrow\; \text{HKDF-Extract}

Concatenation is enough here. TLS 1.3 already hashes the whole transcript, including both public shares, into the key schedule, so the KDF output is unpredictable as long as either half of the input is.

Why is the order reversed?

The name says X25519 first, but the bytes put ML-KEM first. RFC 10024 notes the mismatch on purpose: NIST SP 800-56C approves an HKDF over two concatenated secrets only when the first one comes from a FIPS-approved scheme. X25519 is not approved, and ML-KEM is, so ML-KEM goes first. For the P-256 and P-384 hybrids the curve is approved, so it goes first. That ordering lets a FIPS-validated module use any of the three groups, as long as the component that comes first is the validated one.

The ClientHello size problem

A classical ClientHello is a few hundred bytes. Adding a 1,216-byte key share (browsers and OpenSSL usually also send a plain 32-byte X25519 share as a fallback) pushes it past the roughly 1,500-byte Ethernet MTU. The ClientHello now spans two TCP segments, or two QUIC packets.

TLS has always allowed that, but a surprising amount of software assumed the first read() returns the whole ClientHello. When Chrome turned the hybrid on by default, some servers, proxies and inspection firewalls started failing or timing out. The tldr.fail project tracks affected products. Most vendors (Cisco, Palo Alto, Zscaler, Envoy, Broadcom ProxySG and others) have shipped fixes. If you run your own TLS parsing, SNI routing or passthrough proxy, test it: the fix is to read the two-byte record length and loop until the full record has arrived.

Who already supports it

As of September 2026, X25519MLKEM768 is on by default in most mainstream clients, so the server side is usually the missing half:

Enable and verify it

OpenSSL 3.5 command line

The quickest check against any server. The output reports the negotiated group:

$ openssl version
OpenSSL 3.5.x ...

$ openssl s_client -connect example.com:443 -groups X25519MLKEM768 -brief </dev/null
...
Negotiated TLS1.3 group: X25519MLKEM768

# If the server does not support it, the handshake fails with
# only this group offered. Add a fallback to see what it picks:
$ openssl s_client -connect example.com:443 -groups X25519MLKEM768:X25519 -brief </dev/null

nginx

nginx uses whatever groups its OpenSSL offers. Built against OpenSSL 3.5 with no ssl_ecdh_curve directive, it already prefers the hybrid. If your config pins curves, add it explicitly:

# nginx must be linked against OpenSSL 3.5+ (check: nginx -V)
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ecdh_curve X25519MLKEM768:X25519:prime256v1:secp384r1;

Go

Go 1.24+ enables it automatically, unless you set CurvePreferences. Old code that pinned X25519 and CurveP256 silently opts out, so check for it. Go 1.25 added ConnectionState.CurveID to see what was negotiated:

cfg := &tls.Config{MinVersion: tls.VersionTLS13}
// Don't set CurvePreferences, or include tls.X25519MLKEM768 in it.

conn, err := tls.Dial("tcp", "example.com:443", cfg)
if err != nil {
    log.Fatal(err)
}
defer conn.Close()
fmt.Println(conn.ConnectionState().CurveID) // Go 1.25+: X25519MLKEM768

// Opt out (e.g. while debugging a middlebox): GODEBUG=tlsmlkem=0

curl, browsers and Wireshark

# curl linked against OpenSSL 3.5+
$ curl -sv --curves X25519MLKEM768 https://example.com -o /dev/null 2>&1 | grep -i 'SSL connection'
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / X25519MLKEM768 / ...

Pitfalls

Check Your Understanding

  1. Q1. An attacker records a TLS session that used X25519MLKEM768 and later obtains a quantum computer that breaks X25519. What can they decrypt?

  2. Q2. Why does the X25519MLKEM768 key share put the ML-KEM part first, when SecP256r1MLKEM768 puts the elliptic-curve part first?

  3. Q3. After enabling hybrid TLS, some clients time out behind an old firewall, while classical-only clients work. What is the most likely cause?

Next steps

Hybrid TLS is usually the first and cheapest win in a post-quantum migration: mostly a library upgrade and a config audit. The rest is harder: finding every place your organization does key exchange, deciding what must be protected for how long, and planning certificate and signature changes. The PQC migration checklist walks through that inventory. If you want to do it hands-on with a group, the December live cohort covers building ML-KEM from the spec and deploying and verifying hybrid TLS on real infrastructure.

Further reading