Skip to content

How post-quantum key exchange changes the TLS fingerprint surface

· 18 min read
Copyright: MIT
X25519MLKEM768 wordmark over a dark background, with one orange key-share block

For about a decade the first packet a browser sends on an HTTPS connection was small enough to ignore. A ClientHello with an X25519 key share runs a few hundred bytes. It fit inside one TCP segment, one TLS record, one read(). Servers, load balancers, and middleboxes all quietly assumed it always would. Then Chrome started attaching a 1,184-byte post-quantum key to every handshake, the ClientHello blew past the segment boundary, and the first message of a TLS connection stopped fitting in one packet.

That change is interesting for cryptographers because it defends against harvest-now-decrypt-later. It is interesting for anyone who fingerprints traffic for a different reason. A handshake that spans two packets, carries a 1,216-byte key share, and advertises a named group with codepoint 0x11EC is now the baseline for a real browser. A TLS client that does none of that has not just a different fingerprint. It has an obviously older one, on a feature that shipped to the entire Chrome install base in 2024. The post-quantum rollout handed passive observers a new, hard-to-fake discriminator, and it did so by accident.

This piece walks the mechanism end to end. What the hybrid key share actually contains and why it is exactly the size it is. How it fragments the ClientHello on the wire, and what that does to the people parsing those bytes. How JA3 and JA4 do and do not capture the change. Why most non-browser TLS stacks did not send a post-quantum key share for a long time after Chrome did, and what closing that gap requires. The through-line: a security upgrade quietly raised the floor on what a convincing browser handshake looks like.

What X25519MLKEM768 puts on the wire

The group everyone ships is X25519MLKEM768. It is hybrid, meaning the client offers two key-exchange algorithms at once and the final secret mixes both: the classical X25519 elliptic-curve Diffie-Hellman that browsers have used for years, and ML-KEM-768, the lattice-based key encapsulation mechanism NIST standardized as FIPS 203 (it was called Kyber-768 through the competition). The idea is that an attacker has to break both to recover the session key, so the handshake stays secure even if X25519 falls to a quantum computer later.

The cost of carrying two algorithms is size. ML-KEM-768’s encapsulation key, the public value the client sends, is 1,184 bytes. The X25519 ephemeral share is 32 bytes. Concatenated, the client’s key share for X25519MLKEM768 is 1,216 bytes. The server replies with an ML-KEM ciphertext of 1,088 bytes plus its own 32-byte X25519 share, for 1,120 bytes coming back. Compare that to a pure X25519 exchange, where each side sends 32 bytes. The post-quantum key share is roughly thirty-eight times larger than the thing it sits next to.

Client key_share size, on the wire X25519 32 bytes X25519MLKEM768 1216 bytes white = ML-KEM-768 encap key (1184 B) orange = X25519 share (32 B) *The hybrid client share is the 1,184-byte ML-KEM-768 encapsulation key with the 32-byte X25519 share appended. The classical part is the thin orange sliver on the right.*

One detail in the standard matters for anyone reconstructing the bytes. The naming convention for hybrid groups usually puts the classical share first, but X25519MLKEM768 reverses it: the client sends the ML-KEM encapsulation key first, then the X25519 share. The TLS working group’s draft, Post-quantum hybrid ECDHE-MLKEM Key Agreement for TLSv1.3, calls this out explicitly because it breaks the convention its own companion document defines. The sibling group SecP256r1MLKEM768, codepoint 0x11EB, keeps the conventional order with the secp256r1 share first. If you are parsing a key_share entry and trying to split it back into its two halves, the order is group-specific, and getting it wrong on X25519MLKEM768 means slicing the X25519 bytes out of the middle of the ML-KEM key.

The codepoints themselves are part of the story. X25519MLKEM768 is 0x11EC, decimal 4588, registered in the TLS Supported Groups registry. Before the NIST standard was final, Chrome and others shipped a draft hybrid called X25519Kyber768Draft00 under codepoint 0x6399. That value is now a fossil. Seeing 0x6399 in a 2026 ClientHello does not say “modern browser,” it says “something pinned to a 2023-era draft,” which is its own kind of tell. The migration from 0x6399 to 0x11EC is a clean dividing line in packet captures, and it lines up almost exactly with the Chrome 131 release in November 2024.

Why one extra extension splits the packet

A ClientHello is not sent raw on TCP. It sits inside a TLS record, and the record sits inside one or more TCP segments. The relevant limit is the TCP maximum segment size, which on a typical Ethernet path with a 1,500-byte MTU lands around 1,460 bytes of payload after the IP and TCP headers. A classical ClientHello, even a fat one with a full extension list, comes in well under that. It fits in a single segment, and historically it almost always did.

Add a 1,216-byte key share and the math changes. The key share alone is most of a segment. Stack the cipher list, the supported-groups list, ALPN, SNI, signature algorithms, the supported-versions extension, key-share entries for the other groups the client also offers, GREASE padding, and the record-layer and handshake headers on top, and the whole ClientHello runs past 1,500 bytes. It no longer fits in one packet. The client has to split it, either across two TCP segments or across two TLS records, and the server has to be willing to read it in more than one piece.

Classical ClientHello — one segment ClientHello (~520 B) MSS ~1460 Post-quantum ClientHello — two segments segment 1: hdr + ML-KEM key (part) segment 2: rest + exts *Once the key share alone is most of a segment, the ClientHello overflows the MSS and the server must read it in more than one piece.*

This is where protocol ossification bites. The TLS spec has always allowed a ClientHello to span multiple records and multiple segments. But for years it effectively never did, so a lot of server and middlebox code was written against the assumption that the first read() returns the whole thing. The clearest public write-up of the failure is tldr.fail, which lays the bug out at the byte level: a correct server reads the two-byte record length field at byte index 3 of the TLS record, then keeps calling read() in a loop until it has that many bytes. The author’s blunt summary is that most buggy servers “are not prepared to have to call read() more than once to read the entire ClientHello.” When the rest of the message arrives in a second segment those servers never go back for it. They either hang or reset the connection.

Cloudflare hit the upstream version of this in its own rollout to origin servers. When testing post-quantum key shares directly against origins, a small fraction of them, under a third of a percent, failed to complete the handshake, and the cause traced back to the split ClientHello. The workaround is a HelloRetryRequest dance: advertise support for the post-quantum group while sending only a small classical X25519 key share in the first ClientHello, and let the server ask for the post-quantum share on the second round trip if it wants it. That keeps the first packet small and dodges the ossified middleboxes, at the cost of an extra round trip. It is a useful reminder that the size problem is not theoretical. It broke real deployments, and the industry spent real effort routing around hardware that assumed a ClientHello fits in one packet.

The history goes back further than the current rollout. A 2019 experiment running post-quantum key exchange through Chrome found middleboxes from a particular vendor dropping connections that carried a split ClientHello. The 2018 CECPQ2 trial, with the larger NTRU-HRSS key share, saw a small but real fraction of clients break for the same reason. None of this was a surprise by the time ML-KEM shipped. It was a known tax, paid deliberately.

What the rollout timeline looks like

Chrome moved first and moved fast. The draft hybrid X25519Kyber768Draft00 went to desktop stable in Chrome 124 in April 2024, then got rolled back briefly when enterprise TLS middleboxes choked on the larger handshake, exactly the ossification problem above. The standardized X25519MLKEM768, with codepoint 0x11EC, became Chrome’s default in Chrome 131 in November 2024. Firefox followed with 132 in the same month. Microsoft Edge, which tracks Chromium, made X25519MLKEM768 its default hybrid group from version 131. By 2025 the enterprise escape hatch in Chrome, the PostQuantumKeyAgreementEnabled policy, was on a path to removal, which makes the feature non-optional rather than a toggle.

The deployment numbers come mostly from Cloudflare, which sits in front of a large slice of the web and publishes what it sees. By mid-September 2025 around 43 percent of human-generated connections reaching Cloudflare used the hybrid post-quantum key agreement. By late October 2025 that figure crossed 50 percent. So a majority of real human TLS traffic to a major edge now carries the post-quantum key share. That is the number that makes the fingerprint angle real. When more than half of genuine browser connections do something, the connections that do not stand out by their absence.

There is a second-order effect worth naming. Because the post-quantum group is what virtually every current browser ships, and because Cloudflare reports X25519MLKEM768 as roughly 95 percent of the post-quantum connections it sees, the group is converging hard. There is very little legitimate diversity. A real Chrome, a real Firefox, and a real Edge all put 0x11EC near the front of their supported-groups list and all attach a 1,216-byte key share for it. That convergence is good for the fingerprinter and bad for anyone trying to blend in with an old stack, because the “normal” set has narrowed.

What JA3 sees, what JA4 sees, and the gap between them

The obvious question for anyone who fingerprints handshakes: does the standard tooling even capture this? The answer depends on which tool, and it is more subtle than “JA4 is newer so it catches everything.”

JA3, the 2017 fingerprint, hashes a comma-joined string of five fields from the ClientHello: the version, the cipher list, the extension list, the elliptic-curve (supported-groups) list, and the EC point formats. Crucially, the supported-groups list is one of the five inputs. So when a browser adds X25519MLKEM768 to its supported groups, the decimal value 4588 lands in the curves field, the joined string changes, and the MD5 changes. JA3 does register the post-quantum group, by way of the supported-groups list it already hashed. A scraper that bolts the group on, or one that omits it, produces a different JA3 from a browser that gets it right. The catch with JA3 is the same catch that retired it: Chrome randomizes its extension order, so the JA3 of real Chrome is not stable in the first place, and matching it has been a moving target since 2023. That is covered in our walk from JA3 to JA4.

JA4 is more deliberate, and its treatment of the post-quantum group is the part people get wrong. JA4’s three sections are: a prefix with the protocol, version, SNI flag, cipher count, extension count, and first ALPN value; a SHA-256 truncation over the sorted cipher list; and a SHA-256 truncation over the sorted extension list followed by the signature algorithms. The supported-groups extension is type 0x000a. It appears in the JA4 extension list as that two-byte type identifier, sorted in with every other extension. But the contents of the supported-groups extension, the actual list of named groups including 0x11EC, are not hashed by JA4. JA4 records that the supported-groups extension is present. It does not record which groups are inside it.

supported_groups extension (type 0x000a) 000a 11ec 001d 0017 0018 ... JA3 hashes the group values too — 4588 lands in the curves field JA4 hashes only the type 000a — the group list inside is invisible to it *JA3 folds the named-group values into its hash, so 0x11EC moves the fingerprint. JA4 records only that the supported-groups extension is present, not what is inside it.*

The practical consequence is counterintuitive. The post-quantum group changes a connection’s JA3 but, on its own, does not change its JA4 hash, because JA4 never looked inside supported_groups. What JA4 does capture is everything that travels with the group in a real browser handshake: the cipher count, the extension count, the extension set and its presence of the key-share extension 0x0033, and the signature algorithms. A handshake that claims to be Chrome but is missing the key-share machinery, or has the wrong extension count because it left out the post-quantum plumbing, drifts on JA4 even though the group value itself is not in the hash. And the JA4 prefix counts extensions, so adding or removing the extensions that accompany post-quantum support nudges that count, which is in the human-readable part of the fingerprint.

So neither hash, on its own, is the whole detection. A defender who wants to catch a missing post-quantum key share most directly does not lean on JA4 alone. The strongest signal is dead simple and sits outside both hashes: parse the key_share extension, look for an entry under group 0x11EC, and check whether it carries a ~1,216-byte share. Cross-reference that against the User-Agent. A request that says Chrome 131 in its User-Agent but offers no 0x11EC key share is internally inconsistent, and an edge can decide that before a single byte of the HTTP request is processed. That cross-check is the new tell, and it is cheap.

Why most non-browser stacks were late, and what catching up costs

The reason the post-quantum key share is a useful discriminator is that browsers got it long before the libraries scrapers are built on. Browser TLS is BoringSSL or NSS, maintained by the same teams shipping the post-quantum upgrade, so Chrome and Firefox had it the moment it was ready. The general-purpose TLS stacks moved on their own schedules, and the gap was wide for a while.

Python’s requests and httpx sit on top of OpenSSL through the standard library’s ssl module. Whether a Python HTTPS call offers a post-quantum key share depends on the OpenSSL build underneath, and for a long stretch the OpenSSL that shipped with most Python installs did not enable the hybrid group by default, if it supported it at all. A vanilla requests call, in other words, sends a classical ClientHello with no 0x11EC key share. Against an edge that checks for one and cross-references the User-Agent, that is a flat contradiction. The deeper reasons a default Python client is easy to pick out are covered in why your Python requests is fingerprintable; the post-quantum gap is one more line item on that list, and a newly load-bearing one.

Go’s standard library moved relatively early. Go 1.24 added X25519MLKEM768 as CurveID 4588, enabled it by default, and removed the old Kyber draft. A program built with Go 1.24 or later, using crypto/tls with default settings, sends the post-quantum key share without any extra configuration. The escape hatch is GODEBUG=tlsmlkem=0. That makes Go one of the few non-browser stacks where the default handshake is post-quantum, which closes one fingerprint gap, though Go’s ClientHello differs from a browser’s in plenty of other ways that the standard library does nothing to hide.

The libraries built specifically to imitate browser ClientHellos are the interesting case, because for them the post-quantum share is not optional cosmetics, it is the whole job. uTLS, the Go fork that exposes low-level control of the ClientHello for mimicry, needed an explicit change to keep its Chrome and Firefox parrots current. An issue filed in February 2025 asked for X25519MLKEM768 support precisely because crypto/tls had defaulted to it and the browsers had shipped it, leaving uTLS’s older parrots producing a handshake that no current browser would send. That issue was closed through a follow-up pull request. The subtle part, and the part that matters for whether mimicry actually works, is the difference between emitting the right bytes and performing the real key exchange. A fingerprint-faithful parrot has to put a correctly sized, correctly ordered key_share entry for 0x11EC in the ClientHello so the handshake looks right on the wire. But if the server selects the post-quantum group and replies with an ML-KEM ciphertext, the client has to actually decapsulate it to derive the session key. A stack that only copied the byte shape, without a working ML-KEM implementation behind it, would produce a perfect-looking ClientHello and then fail the handshake the moment the server takes it up on the offer.

ClientHello with 0x11EC key_share server selects PQ, sends ML-KEM ct must decapsulate to derive session key byte-faithful but no real ML-KEM → handshake fails here *Copying the ClientHello byte shape gets you a convincing fingerprint. Completing the handshake when the server selects the post-quantum group requires a real ML-KEM decapsulation, not a recorded value.*

That distinction is why the post-quantum share is harder to spoof than a typical extension reordering. Most fingerprint mismatches are fixed by rearranging bytes you already have. This one asks for a working lattice KEM wired into the handshake state machine. It raises the bar from “emit the right ClientHello” to “be a TLS stack that actually does post-quantum key exchange.” Plenty of tooling has gotten there. But the requirement is a real implementation, not a copied byte string, and that is a meaningfully higher floor than the field is used to.

What matching a 2026 Chrome handshake takes

Putting it together, a client that wants its handshake to pass as current Chrome on the post-quantum axis has a specific checklist, and every item is a place to drift. It has to offer X25519MLKEM768 at codepoint 0x11EC and place it where Chrome places it in the supported-groups list, near the front. It has to attach a real key_share entry for that group, 1,216 bytes, with the ML-KEM encapsulation key first and the X25519 share second, because that group reverses the usual order. It has to let the resulting ClientHello grow past the single-segment size and be willing to send it across two packets, the same fragmentation that breaks ossified servers. And it has to back all of that with an ML-KEM-768 implementation that can decapsulate the server’s ciphertext if the server picks the post-quantum group, not just a recorded share that looks right until the handshake proceeds.

None of those items is exotic. All of them together are a real implementation rather than a cosmetic patch. That is the shift. A scraper used to be able to look like a browser by getting the cipher order and extension order right, which is byte rearrangement. The post-quantum group adds a requirement that is not satisfiable by rearranging bytes, and it sits on a feature that more than half of real human traffic now exercises by default. The result is a discriminator that is both common in the wild and expensive to fake convincingly, which is an unusual combination. Most fingerprint signals are one or the other.

It is also a discriminator that gets sharper with time, not duller. As the PostQuantumKeyAgreementEnabled policy disappears and the holdout middleboxes get patched or replaced, the floor under “real browser” keeps rising. The classical-only ClientHello, the one that fit neatly in a single packet for a decade, is sliding from “normal” to “legacy” to “anomalous.” It will not vanish, embedded devices and old stacks will keep emitting it for years, but it stops being what a current desktop browser does. The same upgrade that protects today’s traffic from tomorrow’s quantum computer also quietly drew a line across the population of TLS clients, and which side of that line a handshake falls on is now legible at the edge before any request is read. Anyone fingerprinting traffic in 2026 inherited a new axis for free, and it is one of the few where doing the spoofing right means shipping a lattice cryptosystem rather than reshuffling a byte string.


Sources & further reading

Further reading