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.

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, 0x2a2a and so on up to 0xfafa, 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:

FieldCompare asWhy
Extension typesSorted set, GREASE removedThe order is shuffled per connection
Extension countNumber, GREASE includedCatches an extension present on one side only
Cipher suitesOrdered list, GREASE labelledChrome does not shuffle them; the order is a choice
Supported groupsOrdered list, GREASE labelledPreference order is a choice
Signature algorithmsOrdered list, GREASE labelledSame
Supported versionsOrdered list, GREASE labelledSame
ALPNOrdered listh2 before http/1.1 is a choice
Random, session id, key share bytesNot comparedFresh 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.