TLS extension ordering and the Chrome randomization that broke JA3
For six years, the order in which a browser listed its TLS extensions was an identity. Chrome sent them in one sequence, Firefox in another, Safari in a third, and every Go program built on the standard library in a fourth. You did not need to decrypt anything to tell them apart. The ClientHello arrives in the clear, the extension list sits right there in the bytes, and a hash over that list pinned the client. JA3 turned exactly this into a 32-character MD5, and for most of its life the order of the extensions was the part of the input that carried the signal.
Then in January 2023 Chrome started shuffling that order on every connection, and the same browser on the same machine began producing a different JA3 hash for every TCP handshake. A fingerprint that had been a stable label for years went to noise overnight. This post is about that single change: what extension order is, why it leaked, how Chrome’s permutation works at the BoringSSL level, and why the fix that stuck, JA4, was to stop treating order as signal and sort the list before hashing.
The walk goes in order. First, where extension order lives in the ClientHello and why RFC 8446 always said it was free. Then the run-up: GREASE, ossification, and the years of vendors fixating on Chrome’s exact byte layout. Then the change itself, down to the Fisher-Yates shuffle and the one extension that cannot move. Then what it did to JA3, and how JA4’s sorted hash and Cisco’s earlier canonical-ordering work both arrived at the same answer. The through-line is an arms-race move that was not aimed at fingerprinters at all, and broke their tool anyway.
Where extension order lives
A ClientHello is a sequence of fields with no encryption around them. Version, client random, session ID, the cipher suite list, a compression byte, and then a length-prefixed block of extensions. Each extension is a two-byte type, a two-byte length, and a payload. The block is just these laid end to end. The receiver walks it by reading a type, reading a length, skipping that many bytes, and repeating until the block is consumed. For a deeper byte-by-byte tour, the ClientHello field reference covers every field; here the only one that matters is the extension block.
Nothing in that parse depends on the order the extensions arrive in. The receiver indexes by type, not by position. An implementation that wanted supported_versions reads until it finds type 0x002b, wherever it sits. This is by design, and the design is written down.
RFC 8446, the TLS 1.3 specification, says it in Section 4.2: “When multiple extensions of different types are present, the extensions MAY appear in any order, with the exception of ‘pre_shared_key’ (Section 4.2.11) which MUST be the last extension in the ClientHello.” One extension is pinned to the end. Every other extension is free to sit anywhere. A conformant server cannot care about the order, because the standard never promised one.
That freedom is the whole point. Order was never semantic. It carried no information the protocol used. Which is exactly why it made such a clean fingerprint: a field with no functional meaning is a field an implementation can set however it likes, and two implementations almost never make the same arbitrary choice. Chrome’s order was Chrome’s, Firefox’s was Firefox’s, and the difference was free signal for anyone hashing the list.
*The extension block as the parser sees it. Type and length, type and length, until the block ends. Only pre_shared_key has a fixed position.*GREASE and the ossification problem
To understand why Chrome shuffled extensions, you have to understand what Google had already learned the hard way about fixed layouts. The lesson has a name in the TLS world: ossification.
A protocol ossifies when middleboxes, load balancers, and server stacks start depending on the exact shape of what they see today, rather than the shape the specification allows. Once enough deployed boxes assume version 0x0303 always means TLS 1.2, or that the extension list always looks a certain way, you cannot change the real thing without breaking those boxes. The deployed base freezes the protocol in place. TLS 1.3’s designers hit this directly. Some middleboxes choked on handshakes that did not look exactly like TLS 1.2, which is why TLS 1.3 disguises itself as 1.2 on the wire and negotiates the real version through an extension.
Google’s answer to ossification was GREASE, standardized later as RFC 8701. The idea is to send garbage on purpose. Chrome inserts fake cipher suites, fake groups, and fake extensions with reserved values that mean nothing, expecting compliant servers to ignore them. If a server chokes on an unknown extension, it gets caught early, while the values are deliberately meaningless, rather than years later when Google ships a real extension with a new code point. GREASE keeps the joints of the protocol moving so they do not seize. The GREASE values post covers how those reserved code points are generated and why naive fingerprinters trip over them.
GREASE landed in BoringSSL in 2016. David Benjamin’s commit implementing draft-davidben-tls-grease-01 added it to cipher suites, supported groups, and extensions, deriving the reserved values deterministically from the client random so they stayed stable within a connection. One detail from that commit foreshadows the later constraint thinking: the empty GREASE extension goes at the front rather than the end, because, as the code notes, “IBM WebSphere can’t handle trailing empty extensions.” Even in 2016, position mattered to real deployed servers, and Google was already working around it.
Here is the irony that sets up the whole story. GREASE was supposed to keep servers from depending on Chrome’s exact ClientHello. But the GREASE values themselves, and Chrome’s specific extension order, became part of Chrome’s fingerprint. The anti-ossification mechanism got fingerprinted. Chrome’s extension order was, in Google’s own words, already ossified. The defense had become a signature.
How JA3 read the order
JA3, published by John Althouse and colleagues at Salesforce in 2017, took the ClientHello and concatenated five fields into a string: the TLS version, the cipher suite list, the extension list, the supported elliptic curves, and the curve point formats. Each list is rendered as its decimal values joined by dashes, the five groups joined by commas, and the whole string hashed with MD5. Thirty-two hex characters out.
The extension list in that string is the extensions in the order they appeared on the wire, with GREASE values filtered out. Order in, order preserved, order hashed. Two clients that sent the same extensions in a different order produced two different JA3 hashes. That was the intended behavior. It was what let JA3 tell a Go TLS client apart from Chrome even when they happened to offer overlapping extensions, because the ordering differed.
So the JA3 hash had a hidden dependency. It assumed that a given client always sent its extensions in the same order. For every TLS stack in existence in 2017, that assumption held. The order was arbitrary but fixed per implementation. The arbitrariness is what made it discriminating; the fixedness is what made it stable. JA3 leaned on both, and for six years both held.
*Same browser, same machine. The only thing that changed in 2023 was the order of the list, and that was enough to scatter the JA3 hash across the address space.*The change, in detail
The capability that broke JA3 was in BoringSSL a year and a half before it shipped to users. David Benjamin’s commit on 9 June 2021 added ssl_setup_extension_permutation, described in the code as computing “a ClientHello extension permutation for |hs|, if applicable.” It exposed two public knobs, SSL_CTX_set_permute_extensions and SSL_set_permute_extensions, and bumped the BoringSSL API version from 15 to 16. The machinery sat there, off by default, waiting for Chrome to turn it on.
The permutation is a Fisher-Yates shuffle. The implementation builds an array of extension indices and walks it from the back, swapping each position with an earlier one chosen at random, using bytes drawn from RAND_bytes as the source of randomness. The swap reads, in essence:
for i from kNumExtensions - 1 down to 1: j = random_seed[i-1] mod (i + 1) swap(permutation[i], permutation[j])This is the textbook unbiased shuffle. Every ordering of the extension set is equally likely. With roughly fifteen permutable extensions, the number of possible orderings is on the order of 15 factorial, over a trillion. Fastly’s measurement put it the same way: with about 15! permutations available, practically every connection gets a unique JA3.
Two things stay fixed. The permutation is computed once per connection and stored in the handshake state, so every ClientHello within a single connection uses the same order. That consistency matters because HelloRetryRequest, HelloVerifyRequest, and Encrypted Client Hello are all sensitive to extension order across the messages of one handshake; a fresh shuffle on a retry would break them. So the shuffle varies across connections and holds within one. And pre_shared_key, if present, is excluded from the permutation and stays last, because RFC 8446 demands it. The one extension the standard pins down is the one extension the shuffle leaves alone.
GREASE rides along inside this. Chrome still places its GREASE extensions, and they get permuted with the rest, so the fake code points land in different positions per connection too. The result is a ClientHello whose extension block is genuinely shuffled end to end on every handshake, with the single exception the protocol requires.
When it reached users
David Adrian filed the Intent to Ship on 18 November 2022, under the title “TLS ClientHello extension permutation.” The stated motive was to “reduce potential ecosystem brittleness,” with the explicit observation that “a fixed extension order can encourage server implementers to fingerprint Chrome,” which could limit Chrome’s freedom to change its TLS implementation later. Chrome’s own extension ordering, the intent noted, was already ossified. The DevTrial had been running since Chrome 106.
The rollout did not line up cleanly with a single version number. The Chrome Platform Status entry and the Intent point at Chrome 110 as the ship milestone, but the behavior showed up in the wild earlier, in builds tagged 108 and 109, because the feature got enabled by default in the Chromium source before 110 went stable. Fastly’s telemetry pins the inflection precisely: starting 20 January 2023, the share of connections carrying Chrome’s old canonical JA3, cd08e31494f9531f560d64c695473da9, dropped off sharply as the permuting builds reached users. That date, not a version string, is the clean marker for when JA3 stopped working on Chrome.
What it did to JA3
The damage was total for Chrome and clean to describe. JA3 hashes the extension list in wire order. Chrome now sends a different wire order per connection. So Chrome now produces a different JA3 per connection. A signature that had identified billions of Chrome handshakes with one stable hash became, for Chrome, a per-connection random value with no aggregation value at all.
The practical consequences hit two groups in opposite ways. Defenders who had built allow-lists or blocklists keyed on JA3 lost their handle on the single most common browser on the internet. A rule that said “this JA3 is Chrome 109, treat it as a known-good browser” matched nothing the day after the rollout, because the hash that rule encoded was now one ordering out of a trillion. Reputation systems that scored JA3 hashes over time found their Chrome buckets dissolving into singletons.
The other group was anyone trying to impersonate Chrome. Before the change, a scraper using a TLS-impersonation library had to reproduce Chrome’s exact extension order to match its JA3, and getting it wrong meant a JA3 that did not belong to any real browser, an obvious tell. After the change, the target was no longer a fixed order at all. In one sense this made impersonation easier: there is no single order to match. In another it raised the bar, because a client that randomizes its extension order now has to randomize it the way Chrome’s BoringSSL does, with the same permutable set, the same fixed pre_shared_key, and the same GREASE placement. A client that keeps a fixed order while claiming to be a recent Chrome is now itself anomalous, because real Chrome no longer holds still. The detection surface moved rather than vanished. Detecting the libraries that try to match Chrome’s behavior is its own topic, covered in detecting curl-impersonate and uTLS, and the uTLS post covers how the Go side reproduces a browser ClientHello in the first place.
There is a subtlety worth stating plainly. Randomization did not add entropy to the fingerprint in any useful sense. The set of extensions Chrome offers did not change; only their order did. Everything JA3 captured other than order, the cipher list, the curves, the version, was untouched. So the information needed to identify the client was still on the wire. JA3 just happened to fold that information together with the now-useless order and hash the mix. The fix was not to find new signal. It was to stop hashing the noise.
The sorted answer
Two responses to randomization both arrived at the same move, from different directions.
The earlier one came from Cisco. David McGrew’s analysis of TLS fingerprinting showed that if you sort the extensions into a canonical order before hashing, instead of taking them in wire order, you recover almost all the discriminating power. His measurement found that canonical ordering of the TLS extensions in the fingerprint achieves nearly the same level of entropy as the wire order did, because the order was never carrying much independent information in the first place. The set of extensions is what discriminates. Their arrangement was mostly redundant with the set. Sort the list, and randomization has nothing left to scramble, because the sort throws away the only thing the shuffle touched.
The response that became the standard was JA4. John Althouse, the same author behind JA3, released the JA4+ suite on 26 September 2023, with the core TLS fingerprint JA4 under a BSD 3-Clause license. JA4 splits the fingerprint into three readable parts, written a_b_c. The first part is human-readable metadata. The second and third are truncated hashes.
The first part, ja4_a, packs the protocol into a single character (t for TLS over TCP, q for QUIC, d for DTLS), then a two-character TLS version, then d or i for whether SNI is present, then a two-digit count of cipher suites, a two-digit count of extensions, and the first and last characters of the first ALPN value. For a typical recent Chrome you get something like t13d1516h2: TLS 1.3 over TCP, SNI present, 15 ciphers, 16 extensions, ALPN h2. The counts already exclude GREASE.
The second part, ja4_b, is a SHA-256 over the cipher suite list with GREASE removed, sorted into hex order, then truncated to twelve hex characters. Sorting the ciphers is a deliberate choice with the same logic as sorting extensions: the FoxIO documentation notes that applications choose a unique cipher list more often than a unique ordering, and sorting also neutralizes “cipher stunting,” the trick of randomizing cipher order to dodge JA3. The cipher suite ordering post goes deeper on what is lost and kept when you sort ciphers.
The third part, ja4_c, is where the extension randomization gets answered head on. It is a SHA-256, again truncated to twelve hex characters, over the extension list with GREASE removed, sorted by hex value. Two extensions are dropped from the list before hashing, SNI (0x0000) and ALPN (0x0010), because their information already lives in ja4_a. Then, and this is the part that preserves uniqueness, the signature algorithms are appended after the sorted extensions in their original order, not sorted. The FoxIO documentation is explicit that the signature algorithms keep “the order that they appear (not sorted).” So JA4 sorts the thing Chrome randomizes and keeps the order of a thing Chrome does not. A full example fingerprint is t13d1516h2_8daaf6152771_e5627efa2ab1.
Why sorting works and what it costs
Sorting works because order and set were never independent. Chrome offers a particular set of extensions. That set is the fingerprint. The order Chrome chose to send them in was an arbitrary tiebreak that happened to differ between implementations, so it added some separating power, but it was riding on top of the set, not orthogonal to it. McGrew’s entropy measurement is the proof: throw the order away, sort canonically, and the fingerprint loses almost nothing, because almost all the information was in which extensions appeared, not the sequence.
The cost is a small loss of resolution, and it is real. Two clients that offer the same set of extensions in different fixed orders are distinguishable under JA3 and identical under JA4’s ja4_c. In the rare case where extension order was the only difference between two stacks, JA4 merges them. The FoxIO designers judged that acceptable, and the signature algorithm tail is partly there to claw some of that resolution back, since signature algorithms stay in wire order and a stack that randomizes extensions but not signature algorithms still leaks a stable sub-sequence. For most clients, the set plus the ALPN plus the cipher list plus the signature algorithm order is plenty to separate them. The arms-race move that broke JA3 turned out to be one a sorted hash simply ignores.
There is a longer arc here worth naming. Randomization defeats fingerprinting only when it randomizes something the fingerprint depends on and the protocol does not. Extension order qualified, briefly, because JA3 had baked an assumption of fixed order into its hash. The moment the fingerprint stopped depending on order, the randomization stopped mattering. You cannot randomize your way out of a fingerprint that hashes the set, because the set is what you actually have to send to make the connection work. Chrome cannot shuffle away the fact that it offers application_settings and certificate compression and the particular groups it supports. Those are functional. They have to be there. JA4 hashes the part that has to be there and drops the part that does not, and that is why it held where JA3 did not. The fuller comparison of the JA3-to-JA4 transition lives in the from ClientHello bytes to JA4 post; the broader JA4+ suite, including the server, HTTP, and TLS-over-time variants, is in the JA4+ in depth writeup.
What this episode actually shows
The clean lesson is that Chrome’s permutation was never aimed at fingerprinters. Read the Intent to Ship and the motivation is ossification, the same fear that produced GREASE: keep servers from depending on Chrome’s exact byte layout so Chrome stays free to change it. Breaking JA3 was a side effect of an anti-ossification move, and the people who maintained JA3-based detection were collateral. The fingerprinting community treated a January 2023 browser-engineering decision as an attack on their tooling, but Google was solving its own problem, which was that its anti-fixation mechanism had itself become a fixed point.
The deeper lesson is about what kind of signal survives. Order was free, which is exactly why it leaked, and exactly why it could be randomized away at zero cost to the protocol. The extension set is not free. Chrome has to send the extensions it needs, in some order, and a fingerprint over the set rather than the order grabbed the part of the ClientHello that Chrome cannot shuffle without breaking the handshake. The whole episode compressed into one sentence: a fingerprint that depends on a field with no semantic meaning is a fingerprint waiting to be randomized, and the fix is to stop depending on the meaningless field. JA4 did that, and three years on it is still the load-bearing TLS fingerprint at the major CDNs, while Chrome’s extension order has been pure noise the entire time.
Sources & further reading
- Rescorla, E. (2018), RFC 8446: The Transport Layer Security (TLS) Protocol Version 1.3 — Section 4.2 states extensions MAY appear in any order except pre_shared_key, which MUST be last in the ClientHello.
- Benjamin, D. (2021), BoringSSL commit e9c5d72: ClientHello extension permutation — adds
ssl_setup_extension_permutation, the Fisher-Yates shuffle, and theSSL_set_permute_extensionsAPI; bumps API version 15 to 16. - Benjamin, D. (2016), BoringSSL commit 65ac997: implement draft-davidben-tls-grease-01 — the original GREASE commit, deriving values from the client random and noting the WebSphere trailing-extension workaround.
- Adrian, D. (2022), Intent to Ship: TLS ClientHello extension permutation — Chromium blink-dev thread; states the ecosystem-brittleness and anti-fingerprinting motivation, DevTrial from Chrome 106.
- Chrome Platform Status (2022), TLS ClientHello extension permutation — the feature entry tracking the ship milestone.
- Fastly (2023), A first look at Chrome’s TLS ClientHello permutation in the wild — measures the 20 January 2023 drop-off of Chrome’s canonical JA3
cd08e31494f9531f560d64c695473da9and the ~15! permutation count. - net4people/bbs (2023), Google Chrome TLS extension permutation, Issue #220 — community thread documenting the rollout across Chrome 108–110 and the pre_shared_key constraint.
- FoxIO-LLC (2023), JA4: TLS Client Fingerprinting — the canonical JA4 algorithm: ja4_a metadata, sorted-cipher ja4_b, sorted-extension ja4_c with signature algorithms left in original order, 12-character truncated hashes.
- Althouse, J. (2023), JA4+ Network Fingerprinting — release announcement (26 September 2023), licensing, and the lineage from JA3.
- Stamus Networks (2023), JA3 fingerprints fade as browsers embrace TLS extension randomization — analysis of the impact on JA3-based detection and how JA4 sorting restores stability.
- Peakhour (2023), Chrome’s TLS extension randomisation experiment — summarizes McGrew’s finding that canonical ordering recovers nearly the same entropy as wire order.
- Cloudflare (2023), Advancing threat intelligence: JA4 fingerprints and inter-request signals — Cloudflare’s adoption of JA4 and why JA3 became unreliable once browsers randomized extension order.
Further reading
JA4+ in depth: JA4, JA4S, JA4H, JA4L, JA4X and what each captures
A reference walk through the full JA4+ suite: how each of JA4, JA4S, JA4H, JA4L, JA4X, JA4T and JA4SSH is constructed, what it captures, and how the a_b_c format lets the parts compose.
·22 min readHow Cloudflare uses TLS and HTTP/2 fingerprints in bot scoring
A reference on Cloudflare's network-layer fingerprinting: how JA3, JA4, and the HTTP/2 frame profile are computed at the edge, what cf.bot_management exposes, and how those signals feed the 1-99 bot score.
·23 min readTLS fingerprinting: from ClientHello bytes to JA4
What a ClientHello actually contains, why JA3 worked for six years and then stopped, and what JA4 fixes, with a Python reference you can run against your own packet captures.
·15 min read