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):
| Group | Codepoint | Recommended | Components |
|---|---|---|---|
| X25519MLKEM768 | 0x11EC (4588) | Y | ML-KEM-768 + X25519 |
| SecP256r1MLKEM768 | 0x11EB (4587) | N | P-256 ECDH + ML-KEM-768 |
| SecP384r1MLKEM1024 | 0x11ED (4589) | N | P-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:
| Field | Layout | Bytes |
|---|---|---|
Client key_exchange | ML-KEM-768 encapsulation key (1184) ‖ X25519 public key (32) | 1216 |
Server key_exchange | ML-KEM-768 ciphertext (1088) ‖ X25519 public key (32) | 1120 |
| Shared secret | ML-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):
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:
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:
- The client generates an ML-KEM-768 key pair and an X25519 key pair and sends both public keys.
- 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.
- The client decapsulates the ciphertext to recover the same ML-KEM secret and finishes X25519 on its side.
- 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:
| Software | Default since | Source |
|---|---|---|
| Chrome (desktop) | Chrome 131, replacing the Kyber draft group | Google Security Blog |
| Firefox | Supported since Firefox 132 (TLS), Firefox 135 (HTTP/3). Pref security.tls.enable_kyber defaults to true | Release notes |
| Apple: URLSession and Network framework | The 26 releases: iOS, iPadOS, macOS Tahoe, visionOS (network guide), plus tvOS and watchOS (security guide) | Apple Support, Platform Security |
| OpenSSL | 3.5.0: hybrid groups in the default list, default key shares X25519MLKEM768 + X25519 | NEWS.md |
Go crypto/tls | Go 1.24, when Config.CurvePreferences is nil. Go 1.26 adds the two NIST-curve hybrids | Go 1.24, Go 1.26 |
| Java (JSSE) | JDK 27, JEP 527: X25519MLKEM768 first in the default list | JEP 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 -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:
$ 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 -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():
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)
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:
--- 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.namedGroupsor 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:
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:
- Find the device that resets. Compare
openssl s_client -groups X25519with the default group list through the same path. If only the large ClientHello fails, the problem is the size, not the certificate. - Patch or replace that device. Disabling the hybrid on clients turns off the protection for every connection, including the ones that worked.
- 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 thePostQuantumKeyAgreementEnabledenterprise 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
- RFC 10024: Post-Quantum Traditional (PQ/T) Hybrid Key Agreement Mechanisms for TLS 1.3 (August 2026). Group definitions, byte order, validation, IANA codepoints.
- RFC 9954: Hybrid Key Exchange in TLS 1.3 (July 2026).
- FIPS 203: Module-Lattice-Based Key-Encapsulation Mechanism Standard (August 2024).
- RFC 9846 §7.1: the TLS 1.3 key schedule.
- Google Security Blog, A new path for Kyber on the web (September 2024).
- Apple Support, Prepare your network for quantum-secure encryption in TLS, and the Apple Platform Security guide.
- OpenSSL 3.5 NEWS.md, Go 1.24 and 1.26 release notes, JEP 527.
- tldr.fail: the large-ClientHello bug and affected products.
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.