# TLS fingerprint mismatch with the User-Agent, and its cause

> A TLS fingerprint mismatch is a User-Agent claiming a browser the handshake did not come from. We avoid it by never claiming a Chrome the engine is not.

Source: https://mosaicbrowser.com/blog/tls-fingerprint-inherited-on-purpose

## Why we leave the TLS and HTTP/2 fingerprints alone

A TLS fingerprint mismatch is a User-Agent claiming a browser the handshake did not come from. We avoid it by never claiming a Chrome the engine is not.

![User-Agent versus ClientHello, each name in its own tile: The handshake has to agree with the header](https://mosaicbrowser.com/covers/tls-fingerprint-inherited-on-purpose.webp)

A TLS fingerprint mismatch with the User-Agent is a header naming one browser over a handshake made by another. A Python script that sends `Chrome/153.0.0.0` from a ClientHello built by `requests` is the common case: the server has the handshake before it has the header, and before any page script runs. Our engine avoids it by doing nothing to the handshake. Chromium makes it, we send it as made, and no profile may claim a Chrome version or brand that handshake does not belong to.

## Where a TLS fingerprint mismatch with the User-Agent comes from

A site learns who you are three times per connection, and a different part of the browser writes each one.

1. The TLS ClientHello: versions, cipher suites, extensions, groups, ALPN. JA3 and JA4 are hashes of it. In Chrome it comes from BoringSSL.
2. The HTTP/2 connection preface: the SETTINGS frame with its values and their order, the window update, the pseudo-header order. Akamai's HTTP/2 fingerprint reads these, and we went through it [field by field](https://mosaicbrowser.com/blog/akamai-http2-fingerprint-read-field-by-field).
3. The headers: `User-Agent`, the `Sec-CH-UA` client hints, `Accept-Language`, and their order.

A setting can reach only the third. The network stack writes the first two. Nearly every mismatch is therefore one move: someone edited layer three and left one and two describing the real client. A User-Agent switcher extension does it. An HTTP library with a pasted Chrome string does it. A proxy that decrypts HTTPS does it too, because the site then reads the proxy's handshake while the browser's headers pass through.

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)

## Why the handshake is inherited, not drawn

All copies of one Chrome version send the same ClientHello shape. Give each profile its own TLS persona and you do not get two ordinary Chrome users; you get one plausible Chrome and one browser that nobody else on the internet runs. Tesseract Chrome is stock Chromium source plus a patch series, and no patch in it touches BoringSSL or the HTTP/2 code. A profile sends what Chromium sends.

That was an argument until we measured it, back when the engine was on Chrome 153. One profile against Google's own Chrome for Testing of the same build, on one machine, a minute apart:

```
ours     JA4 t13d1518h2_8daaf6152771_4980c97edce0
stock    JA4 t13d1518h2_8daaf6152771_4980c97edce0

```

Fifteen cipher suites, 18 extensions, ALPN `h2`, and both hashes equal. The JA3 strings were `995f48479d09` and `624b4feda192`. They differ because Chrome shuffles its extension order on every connection and JA3 hashes the order, so two runs of one binary disagree on JA3 as well; [we compare extensions as a set](https://mosaicbrowser.com/blog/compare-tls-fingerprints-as-sets) for that reason. The shuffle and the GREASE values come through untouched, which leaves a detector no fixed JA3 to learn.

## The version is the part that drifts

Agreement needs a matching claim as well as a real handshake, and the claim is where an anti-detect browser can break things without help from anyone.

In the same round, Google Chrome 152 on the same Mac read `t13d1517h2`: 17 extensions where 153 sent 18. The TLS stack changes between majors. A profile whose User-Agent says 152 on an engine that is 153 sends a genuine Chrome handshake from the wrong Chrome, and a detector does not have to look hard to see it. [JA4 moves with the Chrome major](https://mosaicbrowser.com/blog/ja4-moves-with-the-chrome-major) has the full comparison.

So we tie the claim to the engine. The generator writes the User-Agent as `Chrome/<major>.0.0.0` from the profile's major, and the full build number in the client hints is taken from the engine when the two majors agree. At launch the claimed major goes to the engine resolver, so an engine older than the profile is never chosen while a newer one is installed. If the old engine is the only one on the machine, the profile launches anyway and its audit reports that the versions disagree. Refusing would leave the profile unusable; passing in silence would hide the drift.

## The brand drifted once, in the headers

We broke the rule ourselves, one layer up. For a time, 14% of Windows profiles were drawn as Microsoft Edge. On the engine we shipped then, one profile launched twice, with a local server reading the User-Agent and client hints off the wire:

```
without --user-agent   UA  Edg/153.0.8010.37   Sec-CH-UA-Bitness "64"
with    --user-agent   UA  Edg/153.0.0.0       Sec-CH-UA-Bitness ""

```

No real browser sends either row. User-Agent reduction zeroes everything after the major, so an Edge User-Agent never carries a full build number, and nothing sends an empty bitness next to a filled-in architecture hint. The cause was one switch feeding two places: the client-hint version lists, which need the full build, and the User-Agent suffix, which needs the reduced form. And the row a launch got depended on a cache that was empty at every app start, so the first profile of a session wore one identity and the later ones wore the other.

That contradiction sat between two headers, not between a header and the handshake. The fault is the same one: a claim the engine could not spell. The engine refuses Edge now, every profile claims Chrome, and `check:brand` fails the build if the generator ever draws Edge again.

## HTTP/2 follows the same rule

The SETTINGS frame, the initial window update and the pseudo-header order come out of the same network stack as the handshake, and we leave them alone for the same reason. An HTTP CONNECT tunnel or a SOCKS5 proxy keeps all of it, because the proxy moves the encrypted bytes without opening them. [Header order through a tunnel](https://mosaicbrowser.com/blog/chrome-header-order-through-a-proxy) covers the headers. There is one exception, on purpose: Settings › Network can route launches through Proxyman, Charles, mitmproxy, Fiddler or Burp for debugging, and while it is on, sites read the inspector's TLS, not Chrome's.

## How to check your own

Open a JA3/JA4 echo page twice on one machine, once in the browser under test and once in a stock Google Chrome of the same major. Compare the JA4, not the JA3. Then check that the `Chrome/` major in the User-Agent equals the version `chrome://version` shows. A JA4 equal to stock Chrome's and a major equal to the engine's means the three layers agree. Our detection catalogue has a JA3/JA4 echo row for this check, and [why our engine has no TLS fingerprint setting](https://mosaicbrowser.com/blog/no-tls-fingerprint-setting) gives the reasons there is no per-profile switch.

We would revisit this for a detector that scores a handshake we send worse than stock Chrome's. So far we have not found one.

## Questions

### What is a TLS fingerprint mismatch?

The User-Agent header names one browser and the TLS handshake that carried it was made by something else, such as a Python library, a proxy that decrypts traffic, or a different Chrome version. A server can see both before any page script runs.

### Does changing the User-Agent change the TLS fingerprint?

No. The User-Agent is an HTTP header sent inside the encrypted connection; the ClientHello is built by the TLS library before that connection exists. Changing one leaves the other exactly as it was.

### Can two versions of Chrome have different TLS fingerprints?

Yes. In one of our measurements on a Mac, Google Chrome 152 sent 17 TLS extensions and Chrome 153 sent 18, so their JA4 strings differ. Compare a handshake only with a Chrome of the same major version.

### Does Mosaic let me pick a TLS fingerprint per profile?

No. Tesseract Chrome sends the handshake Chromium sends, and every profile claims a Chrome major the engine can back. That keeps the handshake, the HTTP/2 SETTINGS frame and the User-Agent telling the same story.

## Related

[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.

[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.

[The Akamai HTTP/2 fingerprint, read field by field](https://mosaicbrowser.com/blog/akamai-http2-fingerprint-read-field-by-field): The Akamai HTTP/2 fingerprint is four fields: the SETTINGS values, the window update, any priority frames and pseudo-header order, all read before content.











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