Skip to content
ecdsa.com
← All articles

X25519MLKEM768 explained: hybrid key exchange in TLS 1.3

One key share carries an ML-KEM-768 key and an X25519 key, and both secrets go into the key schedule. Here is the byte layout, the clients that already send it, how to confirm it on your own server, and the old network gear that chokes on it.

Short answer. X25519MLKEM768 is a TLS 1.3 key exchange group, codepoint 0x11EC (4588), that runs ML-KEM-768 and X25519 side by side and feeds both shared secrets into the key schedule. It is standardized in RFC 10024 (August 2026). Chrome, Firefox, Apple's networking APIs in the 26 releases, OpenSSL 3.5, Go 1.24+ and JDK 27 offer it by default. Run openssl s_client -connect example.com:443 -brief with OpenSSL 3.5 and look for Negotiated TLS1.3 group: X25519MLKEM768. It protects recorded traffic against a future quantum computer. It does nothing for authentication, which still rests on RSA or ECDSA certificates.

What the name says

TLS 1.3 negotiates key exchange through named groups. The client lists the groups it supports in the supported_groups extension and sends public keys for one or more of them in key_share. The server picks one group and answers with its own share. For years the realistic choices were X25519 and P-256 ECDH. Both fall to Shor's algorithm on a large enough quantum computer, and traffic recorded today can be decrypted whenever such a machine exists. That is the harvest-now, decrypt-later problem.

A hybrid group answers it without betting everything on new mathematics. X25519MLKEM768 runs two key agreements in a single negotiation:

  • ML-KEM-768, the module-lattice key encapsulation mechanism from FIPS 203 (August 2024), category 3 parameters. This part resists quantum attacks.
  • X25519, the elliptic-curve Diffie-Hellman function from RFC 7748, which has a long cryptanalysis record and backs up the newer component.

The generic construction is described in RFC 9954 (Hybrid Key Exchange in TLS 1.3, July 2026), whose stated goal is security "even if a way is found to defeat the encryption for all but one of the component algorithms." The concrete groups are defined in RFC 10024 (August 2026):

GroupCodepointRecommendedComponents
X25519MLKEM7680x11EC (4588)YML-KEM-768 + X25519
SecP256r1MLKEM7680x11EB (4587)NP-256 ECDH + ML-KEM-768
SecP384r1MLKEM10240x11ED (4589)NP-384 ECDH + ML-KEM-1024

Older captures may show X25519Kyber768Draft00, codepoint 0x6399 (25497). That was the pre-standard Kyber hybrid that Chrome shipped first. FIPS 203 changed the algorithm in small but incompatible ways, so Chrome 131 replaced it with 0x11EC and dropped the draft group. Google's announcement gives the reason it did not support both: "Post-quantum cryptography is too big to be able to offer two post-quantum key share predictions at the same time." RFC 10024 marks the draft codepoints obsolete.

Inside the key share

The hybrid share is a plain concatenation of the two component values, with no length prefixes inside. Sizes are fixed by FIPS 203 and RFC 7748, so each field can be checked by length alone:

FieldLayoutBytes
Client key_exchangeML-KEM-768 encapsulation key (1184) ‖ X25519 public key (32)1216
Server key_exchangeML-KEM-768 ciphertext (1088) ‖ X25519 public key (32)1120
Shared secretML-KEM shared secret (32) ‖ X25519 shared secret (32)64

Note the order. The name starts with X25519, but the bytes start with ML-KEM. RFC 10024 says the order "has been reversed" from the naming convention "due to historical reasons". Its section 5 also gives a compliance reason: NIST SP 800-56C permits a combined secret in a FIPS-approved key derivation only when the first input comes from an approved scheme, and in this pair that is ML-KEM. The two NIST-curve hybrids put the ECDH share first for the same reason. A parser that splits the field at byte 32 instead of byte 1184 will fail every time.

This is a real ClientHello from OpenSSL 3.5.7 to cloudflare.com, captured with -trace on 20 October 2026 (key bytes shortened):

openssl s_client -trace, ClientHello (abridged)
Sent TLS Record
  Content Type = Handshake (22)
  Length = 1551
    ClientHello, Length=1547
        extension_type=supported_groups(10), length=18
          X25519MLKEM768 (4588)
          ecdh_x25519 (29)
          secp256r1 (P-256) (23)
          ecdh_x448 (30)
          secp384r1 (P-384) (24)
          secp521r1 (P-521) (25)
          ffdhe2048 (256)
          ffdhe3072 (257)
        extension_type=key_share(51), length=1258
            NamedGroup: X25519MLKEM768 (4588)
            key_exchange:  (len=1216): A4696B297C695DEA602FB55157BC359F...
            NamedGroup: ecdh_x25519 (29)
            key_exchange:  (len=32): 919D957E3D7E402ECF27C3DD0B38DDA0...

The client sends two shares: the hybrid one and a plain X25519 one. That is OpenSSL 3.5's default. Go 1.27.3 sends the same pair, and so does Chromium 156, which adds a GREASE entry and arrives as a 1704-byte record. JEP 527 specifies the same pair for JDK 27. The plain share costs 32 bytes, and it lets a server that does not know 0x11EC complete the handshake in one round trip instead of asking for another share. The key_share extension adds up exactly: 2 bytes of list length, then 2 + 2 + 1216 for the hybrid entry and 2 + 2 + 32 for X25519, 1258 in total. The server picked the hybrid:

openssl s_client -trace, ServerHello (abridged)
    ServerHello, Length=1206
        extension_type=key_share(51), length=1124
            NamedGroup: X25519MLKEM768 (4588)
            key_exchange:  (len=1120): C01BC77BB1F9985B0DE82CDD1D0DD14B...

For comparison, the same client with -groups X25519 sends a 313-byte ClientHello. The hybrid adds roughly 1.2 KB in each direction, and that size is the source of most compatibility problems (see below).

From two secrets to one key schedule

Each side does two independent operations:

  1. The client generates an ML-KEM-768 key pair and an X25519 key pair and sends both public keys.
  2. The server encapsulates against the ML-KEM key. It gets a 32-byte secret and a 1088-byte ciphertext. It also runs X25519 with its own ephemeral key, then sends the ciphertext and its X25519 public key.
  3. The client decapsulates the ciphertext to recover the same ML-KEM secret and finishes X25519 on its side.
  4. Both sides concatenate the results into 64 bytes and use that value where TLS 1.3 normally puts the (EC)DHE secret: as input to the HKDF-Extract that derives the handshake secret (RFC 9846 §7.1, the July 2026 revision of TLS 1.3 that obsoletes RFC 8446).

Everything after that is ordinary TLS 1.3. The cipher suites, the certificate, CertificateVerify and the traffic keys are unchanged. RFC 10024 adds validation steps that implementations must perform. They check the encapsulation key as FIPS 203 requires, reject a ciphertext of the wrong length, and abort if the X25519 result is all zeros. Each failure ends the handshake with an illegal_parameter or internal_error alert.

The security argument is short. An attacker who records the handshake has to recover both secrets to compute the handshake secret. Breaking X25519 with a future quantum computer is not enough while ML-KEM holds. A cryptanalytic surprise against the young lattice scheme is not enough while X25519 holds. If you want to see encapsulation and decapsulation on real keys, the ML-KEM lab runs them in your browser.

Who turns it on by default

As of October 2026, all of the major clients offer it by default:

SoftwareDefault sinceSource
Chrome (desktop)Chrome 131, replacing the Kyber draft groupGoogle Security Blog
FirefoxSupported since Firefox 132 (TLS), Firefox 135 (HTTP/3). Pref security.tls.enable_kyber defaults to trueRelease notes
Apple: URLSession and Network frameworkThe 26 releases: iOS, iPadOS, macOS Tahoe, visionOS (network guide), plus tvOS and watchOS (security guide)Apple Support, Platform Security
OpenSSL3.5.0: hybrid groups in the default list, default key shares X25519MLKEM768 + X25519NEWS.md
Go crypto/tlsGo 1.24, when Config.CurvePreferences is nil. Go 1.26 adds the two NIST-curve hybridsGo 1.24, Go 1.26
Java (JSSE)JDK 27, JEP 527: X25519MLKEM768 first in the default listJEP 527

Anything linked against OpenSSL 3.5 picks up the default too, as long as its configuration does not override the group list. That covers Debian 13 packages and Node.js 24, which bundles its own OpenSSL 3.5. On Debian 13, Python's ssl module completed a handshake with an openssl s_server -groups X25519MLKEM768 that accepts nothing else, and curl and nginx negotiated the hybrid without extra configuration. Apple's TLS security page notes one gap: "TLS clients using the SecureTransport APIs can't use TLS 1.3", so apps still on that API get no hybrid key exchange.

On the server side, a large share of HTTPS traffic already terminates on stacks that pick the hybrid when the client offers it. A probe from OpenSSL 3.5.7 on 20 October 2026 negotiated X25519MLKEM768 with cloudflare.com, google.com, www.amazon.com, www.microsoft.com, www.apple.com and example.com, and plain X25519 with github.com and letsencrypt.org.

Check a server, a client, a browser

From the command line. OpenSSL 3.5's -brief output names the group directly:

openssl s_client -brief (OpenSSL 3.5.7, 20 October 2026)
$ openssl s_client -connect cloudflare.com:443 -servername cloudflare.com -brief </dev/null
Connecting to 104.16.132.229
CONNECTION ESTABLISHED
Protocol version: TLSv1.3
Ciphersuite: TLS_AES_256_GCM_SHA384
Peer certificate: CN=cloudflare.com
Hash used: SHA256
Signature type: ecdsa_secp256r1_sha256
Verification: OK
Negotiated TLS1.3 group: X25519MLKEM768
DONE

Against a server that picks a classical group, the output shows Peer Temp Key: X25519, 253 bits instead. To test whether a server can do the hybrid at all, offer nothing else. A classical-only server then fails the handshake:

Hybrid only, against a server without it (abridged)
$ openssl s_client -groups X25519MLKEM768 -connect github.com:443 -servername github.com -brief </dev/null
...:error:0A000410:SSL routines:ssl3_read_bytes:ssl/tls alert handshake failure:...:SSL alert number 40

Only use that probe for testing. A real client that offers nothing but the hybrid gives up compatibility for no gain. curl built on OpenSSL 3.5 prints the group in its verbose connection line:

curl -v (curl 8.14.1, OpenSSL 3.5.7)
$ curl -sv -o /dev/null https://www.google.com/ 2>&1 | grep 'SSL connection'
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / X25519MLKEM768 / id-ecPublicKey

From code. Go 1.25 and later expose the negotiated group as ConnectionState().CurveID, and Node.js 24 reports it through getEphemeralKeyInfo():

Go: print the negotiated group
conn, err := tls.Dial("tcp", "cloudflare.com:443", &tls.Config{ServerName: "cloudflare.com"})
if err != nil {
	log.Fatal(err)
}
fmt.Println(conn.ConnectionState().CurveID) // X25519MLKEM768 (Go 1.27.3)
Node.js 24: print the negotiated group
import tls from "node:tls";
const socket = tls.connect({ host: "cloudflare.com", port: 443, servername: "cloudflare.com" }, () => {
  console.log(socket.getEphemeralKeyInfo()); // { type: 'TLSGroup', name: 'X25519MLKEM768', size: undefined } (Node 24.22.0)
  socket.end();
});

In the browser. Chrome exposes the group in DevTools, in the connection details of the security panel. The same value is available programmatically as securityDetails.keyExchangeGroup in the DevTools protocol's Network.responseReceived event. Headless Chromium 156 reported X25519MLKEM768 for www.cloudflare.com and X25519 for github.com on 20 October 2026. On macOS 26, Apple suggests nscurl --tls-diagnostics https://your.host. To see what your browser sends, open the browser post-quantum check.

Turning it on for your server

With OpenSSL 3.5 underneath, the default is already right. The usual way to lose it is an explicit group list copied from an older hardening guide. This nginx 1.30.5 test on Debian 13 (OpenSSL 3.5.7) changes only the ssl_ecdh_curve line and probes with openssl s_client -brief:

nginx 1.30.5: the group follows ssl_ecdh_curve (probe output only, nginx log lines omitted)
--- no ssl_ecdh_curve (OpenSSL default list)
Protocol version: TLSv1.3
Negotiated TLS1.3 group: X25519MLKEM768
--- ssl_ecdh_curve X25519:prime256v1:secp384r1;
Protocol version: TLSv1.3
Peer Temp Key: X25519, 253 bits
--- ssl_ecdh_curve X25519MLKEM768:X25519:prime256v1:secp384r1;
Protocol version: TLSv1.3
Negotiated TLS1.3 group: X25519MLKEM768

So the fix is one of these: remove the line, or put X25519MLKEM768 first in it. Older OpenSSL releases do not know the name, so upgrade the TLS library first. nginx 1.24.0 on OpenSSL 1.1.1w rejects such a line (here ssl_ecdh_curve X25519MLKEM768:X25519:prime256v1;) with nginx: [emerg] SSL_CTX_set1_curves_list("X25519MLKEM768:X25519:prime256v1") failed. Other servers have the same pattern under other names: SSLOpenSSLConfCmd Groups in Apache httpd, curves in HAProxy, CurvePreferences in Go, jdk.tls.namedGroups in Java. If you need a starting point for a specific server, the TLS config generator builds configurations with the hybrid groups in place.

Two more places are easy to forget:

  • The origin behind a CDN or load balancer. The browser's connection ends at the edge, and the edge's connection to your origin is a separate TLS session with its own negotiation. Recorded traffic on that second leg is just as harvestable.
  • Internal service-to-service TLS. Go services get the hybrid from the toolchain version. Java services get it from JDK 27, where JEP 527 added the hybrid groups. Anything that pins CurvePreferences, jdk.tls.namedGroups or another explicit group list gets nothing until someone edits it.

Key share prediction and HelloRetryRequest

A TLS 1.3 client has to guess which group the server will choose and send a share for it up front. If it guesses wrong, the server answers with a HelloRetryRequest naming the group it wants, and the client starts over. That costs a full round trip. The guess matters more with post-quantum shares, because sending a 1216-byte share "just in case" is not free. You can watch the retry happen by telling OpenSSL to list the hybrid but send a share only for X25519:

openssl s_client -groups X25519:X25519MLKEM768 -trace (abridged)
    ClientHello, Length=315
        extension_type=key_share(51), length=38
            NamedGroup: ecdh_x25519 (29)
    ServerHello, Length=84                      <- HelloRetryRequest
        extension_type=key_share(51), length=2
            NamedGroup: X25519MLKEM768 (4588)
    ClientHello, Length=1499
        extension_type=key_share(51), length=1222
            NamedGroup: X25519MLKEM768 (4588)
    ServerHello, Length=1206
Negotiated TLS1.3 group: X25519MLKEM768

The server preferred the hybrid even though the client listed X25519 first, so it asked for a retry. In OpenSSL 3.5 group strings, a * prefix marks the groups that get a share in the first flight. -groups '*X25519:*X25519MLKEM768' sends both shares, a 1535-byte ClientHello, and avoids the retry. The defaults of the major clients send both for the same reason. A TLS working group draft, draft-ietf-tls-key-share-prediction, lets servers publish their preferred groups in DNS HTTPS records so clients can guess right the first time. It was still an Internet-Draft in October 2026.

What breaks: big ClientHellos and old middleboxes

The measured ClientHello above fills a TLS record with 1551 bytes of payload. With the record, TCP and IP headers it no longer fits in one packet on a typical 1500-byte MTU path. The specification has always allowed that, but some implementations assumed the first read() from the socket returns the whole ClientHello. They break when the hybrid splits it across two segments.

The tldr.fail project tracks this bug class. It describes servers "not prepared to have to call read() more than once to read the entire ClientHello". Its list includes TLS-inspecting proxies, firewalls and load balancers from several vendors. Some have since been patched, and some are marked unknown. The symptoms look like network trouble, not crypto: ERR_CONNECTION_RESET or ERR_SSL_PROTOCOL_ERROR in Chrome, handshake timeouts, or resets right after the ClientHello. The bug is often visible only from clients that send the hybrid. Apple warns about the same failure in its notes for the 26 releases: devices may not reach some older servers whose TLS code "fails to read large ClientHello messages."

When you hit it:

  1. Find the device that resets. Compare openssl s_client -groups X25519 with the default group list through the same path. If only the large ClientHello fails, the problem is the size, not the certificate.
  2. Patch or replace that device. Disabling the hybrid on clients turns off the protection for every connection, including the ones that worked.
  3. Use the client-side switches only as a stopgap. Go has GODEBUG=tlsmlkem=0, and the Go 1.24 notes mention it for "buggy TLS servers that do not handle large records correctly". Chrome had the PostQuantumKeyAgreementEnabled enterprise policy, which Google described as "a temporary measure" that "will be removed sometime after Google Chrome version 145". Check that your Chrome version still honors it before you plan around it.

TLS-terminating proxies need one more note. A proxy that decrypts traffic runs two handshakes. Whatever your laptop negotiates with the proxy says nothing about the proxy's connection to the outside world. That leg is only post-quantum if the proxy's own TLS stack supports and prefers the hybrid.

What it does not protect

X25519MLKEM768 protects confidentiality against an adversary who records traffic now and decrypts it later. It does not make the handshake's authentication post-quantum. The server still proves its identity with an RSA or ECDSA certificate and a CertificateVerify signature. Someone who can forge those signatures with a quantum computer could impersonate a server in a live connection. That threat only exists once such a computer exists, which is why key exchange moved first and signatures are a separate, slower migration. The other articles in this series cover that work: ML-DSA keys and signatures in ML-DSA for developers, and the overall plan, including inventory and deadlines, in Post-quantum migration in 2026.

It also does nothing for traffic that is not TLS 1.3. TLS 1.2 connections, SSH sessions and VPN tunnels have their own key exchanges and their own upgrade paths. Find out which of your connections still negotiate TLS 1.2 before you count them as protected.

Sources

All command output on this page comes from containers on 20 October 2026: OpenSSL 3.5.7, curl 8.14.1, Node.js 24.22.0 and Debian's Python 3 in node:24.22.0-trixie, Go 1.27.3 in golang:1.27.3-trixie, nginx 1.30.5 in nginx:1.30.5, nginx 1.24.0 with OpenSSL 1.1.1w in nginx:1.24.0, and Chromium 156 in mcr.microsoft.com/playwright:v1.64.1-noble.