3.7 Hybrid TLS: X25519MLKEM768
The post-quantum key exchange your browser already uses, and how to turn it on for your own servers.
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:
| Group | Codepoint | Client share | Server share | Order |
|---|---|---|---|---|
| X25519MLKEM768 | 0x11EC (4588) | 1,216 bytes | 1,120 bytes | ML-KEM first |
| SecP256r1MLKEM768 | 0x11EB (4587) | 1,249 bytes | 1,153 bytes | ECDHE first |
| SecP384r1MLKEM1024 | 0x11ED (4589) | 1,665 bytes | 1,665 bytes | ECDHE first |
For X25519MLKEM768 the handshake runs like this:
- 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.
- 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.
- 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.
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:
- Browsers: Chrome and Edge 131+, Firefox 132+ on desktop (145+ on Android), Safari 26+.
- Apple platforms: iOS, iPadOS, macOS and visionOS 26 advertise it in every TLS 1.3 ClientHello from the system stack, including
URLSession. - OpenSSL 3.5+: in the default group list, and clients send X25519MLKEM768 and X25519 key shares by default.
- Go: default in
crypto/tlssince Go 1.24. Go 1.26 also enables SecP256r1MLKEM768 and SecP384r1MLKEM1024 by default. - Others: BoringSSL, AWS-LC, rustls 0.23.27+, GnuTLS 3.8.8+, Node.js 22.20+ and 24.5+, Caddy 2.10+, and nginx built against OpenSSL 3.5.
- CDNs: Cloudflare negotiates it with visitors by default, so a site behind Cloudflare is already hybrid on the browser-facing leg. The CDN-to-origin leg is a separate connection you have to check.
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/nullnginx
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=0curl, 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 / ...- Chrome: DevTools → Security panel (called “Privacy and security” in newer versions) → Connection shows the key exchange as X25519MLKEM768.
- Wireshark: filter on
tls.handshake.type == 2and look at the ServerHello key_share group. Recent versions label it X25519MLKEM768; older ones show 4588 (0x11ec).
Pitfalls
- HelloRetryRequest round trips. If the client offers the hybrid in
supported_groupsbut sends only an X25519 key share, a server that insists on the hybrid answers with HelloRetryRequest, which costs a full round trip. Prefer groups the client already sent a share for. OpenSSL 3.5’s default server group list is built to avoid unnecessary retries; custom lists can reintroduce them. - Hard-coded group lists. The most common reason a “modern” stack isn’t hybrid is an old config line or
CurvePreferencessetting. Grep forssl_ecdh_curve,-groups,CurvesandCurvePreferencesbefore you assume the default applies. - Old middleboxes and passthrough proxies. Test the split ClientHello through every hop: load balancers, WAFs, SNI routers, TLS inspection. Watch connection error rates, not just a single manual test.
- FIPS mode. A validated module needs a validated implementation of the component that comes first. Where the ML-KEM part is validated, X25519MLKEM768 is fine (Go’s FIPS 140-3 mode allows it since Go 1.25). Where only the NIST curves are validated, use SecP256r1MLKEM768 instead.
- Only one leg is protected. A hybrid browser-to-CDN connection says nothing about CDN-to-origin, service-to-service or database traffic. Inventory every TLS leg that carries data you need to keep secret for years.
- Draft codepoints. 0x6399 (X25519Kyber768Draft00) was the pre-standard Kyber hybrid. It is not ML-KEM and has been removed from Chrome, Go and others. Don’t configure it.
Check Your Understanding
Q1. An attacker records a TLS session that used X25519MLKEM768 and later obtains a quantum computer that breaks X25519. What can they decrypt?
Q2. Why does the X25519MLKEM768 key share put the ML-KEM part first, when SecP256r1MLKEM768 puts the elliptic-curve part first?
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
- RFC 10024: Post-Quantum Traditional (PQ/T) Hybrid Key Agreement Mechanisms for TLS 1.3 (the X25519MLKEM768 codepoints, sizes and ordering).
- FIPS 203: the ML-KEM standard. See 3.1 Kyber / ML-KEM for how it works inside.
- OpenSSL 3.5 release notes and Go 1.24 release notes: defaults and configuration.
- Apple: Prepare your network for quantum-secure encryption in TLS.
- Cloudflare PQC support matrix and tldr.fail for the split-ClientHello bug.