To compare two TLS fingerprints honestly, compare what the client chose, not what it rolled at random. Chrome shuffles its extension order on every connection and fills a few slots with random GREASE values, so a raw diff of two ClientHellos from the same browser already shows differences. Label every GREASE value as one thing, compare the extension list as a sorted set, and compare the other lists in order.
We learned the rule the plain way. Our first diff of Tesseract Chrome, our engine, against branded Google Chrome on the same Mac showed two differing fields, and both were Chrome being Chrome.
Two false differences on the first run
The capture was a raw TCP listener that reads the first TLS record and hangs up (the listener, and how to parse the record). Each browser was pointed at it on the same machine, and the script printed the fields side by side.
The extension list differed. That was the shuffle. Chrome permutes the order of its ClientHello extensions per connection, deliberately, so that servers cannot come to depend on one order, and it has done so since Chrome 110. Comparing two ordered lists from Chrome compares two shuffles. Two captures from one Chrome would have differed just as much.
The first signature-algorithm entry differed too: 0xdada in one capture, 0x2a2a in the other. Both are GREASE. Our parser already labelled GREASE in the extension list, and that one line printed raw hex, so a random value looked like a different choice. The fix was one mapping applied everywhere before comparing.
With both handled, every field we read matched:
legacy version same cipher suites same
extension set same extension count same
supported groups same supported versions same
signature algs same ALPN same
The fingerprint is changed inside the engine, not painted on topIn Mosaic: Its own Chromium. Tesseract Chrome: values changed in C++, not JavaScript. Free holds 3 profiles. Pro is $49 a month for any number.
Mosaic is a desktop app
Email yourself the link and open it on your computer.
How it works: Its own Chromium
What GREASE is, and how to recognise it
GREASE comes from RFC 8701. It reserves code points that a client sends at random, so a server that chokes on an unknown value breaks on day one rather than the day a real new value ships. The values follow one pattern, two equal bytes ending in a:
- For extensions, named groups, signature algorithms and versions:
0x0a0a,0x1a1a,0x2a2aand so on up to0xfafa, sixteen in all. - For cipher suites and ALPN identifiers: the same sixteen, written as byte pairs.
- For PSK key exchange modes: eight single bytes, starting
0x0b,0x2a.
The pattern is easy to test for, and that is the point: replace any of them with the word GREASE as you parse, in every list, before anything is compared. Where GREASE sits in a list is still worth keeping. Which of the sixteen values turned up is noise.
How to compare two TLS fingerprints, field by field
Treating every list as a set throws away real information, because only the extensions are shuffled. Here is how our probe, scripts/probe-clienthello.mjs, treats each field:
| Field | Compare as | Why |
|---|---|---|
| Extension types | Sorted set, GREASE removed | The order is shuffled per connection |
| Extension count | Number, GREASE included | Catches an extension present on one side only |
| Cipher suites | Ordered list, GREASE labelled | Chrome does not shuffle them; the order is a choice |
| Supported groups | Ordered list, GREASE labelled | Preference order is a choice |
| Signature algorithms | Ordered list, GREASE labelled | Same |
| Supported versions | Ordered list, GREASE labelled | Same |
| ALPN | Ordered list | h2 before http/1.1 is a choice |
| Random, session id, key share bytes | Not compared | Fresh on every connection |
Two conditions make the comparison fair before any of this. Capture both sides on a cold connection, because a resumed TLS 1.3 session adds a pre_shared_key extension that a first connection does not carry. And compare against a Chrome of the same major version. Chrome changes its handshake between majors: the JA4 of Chrome 152 and 153 differ by one extension, which we traced in why a JA4 moves with the Chrome major.
Where JA3 and JA4 fit
The hashes are comparisons someone has already packaged. JA3 hashes the extensions in the order they arrived, so it changes on almost every Chrome connection and two JA3s from one browser rarely agree (why Chrome's JA3 changes works through it). JA4 sorts the extensions and drops GREASE before hashing, which is the set rule above, and it holds still across connections.
So two equal JA4 strings are a good first answer. They are not the whole answer: a hash says the inputs differ, not which one, and a sorted hash cannot show an ordering difference in a list that should have kept its order. When two JA4s disagree, go back to the parsed fields.
What the comparison settled for us
For our engine it settled one question. The handshake is identical to branded Chrome's, field by field, and it is not where a detector finds the difference. It is also why our engine has no TLS fingerprint setting: every real Chrome of one version sends this shape, so a profile that sent another would stand out.




