# How to compare two TLS fingerprints: sets, not order

> To compare two TLS fingerprints, label GREASE and compare the extension set sorted. Our first diff against Chrome showed two differences, both noise.

Source: https://mosaicbrowser.com/blog/compare-tls-fingerprints-as-sets

## Comparing two TLS handshakes: compare the set, not the order

To compare two TLS fingerprints, label GREASE and compare the extension set sorted. Our first diff against Chrome showed two differences, both noise.

![Ordered list versus Sorted set, each name in its own tile: Two handshakes, compared field by field](https://mosaicbrowser.com/covers/compare-tls-fingerprints-as-sets.webp)

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](https://mosaicbrowser.com/blog/reading-a-client-hello-in-a-minute)). 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

```

Mosaic is a desktop app

Email yourself the link and open it on your computer.

[How it works: Its own Chromium](https://mosaicbrowser.com/features/engine)

## 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:

| 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](https://mosaicbrowser.com/blog/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](https://mosaicbrowser.com/blog/why-chromes-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](https://mosaicbrowser.com/blog/no-tls-fingerprint-setting): every real Chrome of one version sends this shape, so a profile that sent another would stand out.

## Questions

### How do I compare two TLS fingerprints?

Capture both ClientHellos from cold connections, replace every GREASE value with one label, sort the extension list and compare it as a set, and compare the cipher suites, groups, signature algorithms, versions and ALPN field by field in their own order.

### Why do two captures of the same Chrome look different?

Chrome shuffles the order of its TLS extensions on every connection and picks GREASE values at random each time. Two captures from one browser a second apart differ in both, and neither difference says anything about the browser.

### What are GREASE values in TLS?

Reserved code points defined in RFC 8701, such as 0x0a0a, 0x2a2a and 0xdada, that a client sends at random so servers learn to ignore values they do not know. There are sixteen of them for extensions, groups, signature algorithms and versions.

### Is comparing JA4 strings enough?

It is a good first check, because JA4 sorts the extensions and drops GREASE before hashing. Two equal JA4 strings still hide some detail, so when they differ, or when you need to know which field moved, compare the parsed fields.

## Related

[Reading a browser's ClientHello with a one-minute listener](https://mosaicbrowser.com/blog/reading-a-client-hello-in-a-minute): A raw TCP listener that reads the first TLS record and hangs up captures a browser's ClientHello. Ours and branded Chrome's matched in every field.

[Why Chrome's JA3 hash changes on every connection](https://mosaicbrowser.com/blog/why-chromes-ja3-changes): Chrome's JA3 changes because Chrome shuffles its TLS extension order on every connection, and JA3 hashes that order. JA4 sorts first and holds still.

[JA4 moves with the Chrome major, and that is not a tell](https://mosaicbrowser.com/blog/ja4-moves-with-the-chrome-major): Chrome 152 read t13d1517h2 and 153 read t13d1518h2: one TLS extension apart. Compare a JA4 only against a Chrome of the same major version.

[Why our engine has no TLS fingerprint setting](https://mosaicbrowser.com/blog/no-tls-fingerprint-setting): You can't change Chrome's TLS fingerprint from a setting, and we chose not to patch it: our engine's JA4 matched Google's own build byte for byte.









## Mosaic, on your own computer

The browser this blog is about: its own Chromium, fingerprints from real machines and a proxy exit per profile. 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.
