# Accept-CH header and Critical-CH, explained

> Accept-CH asks Chrome for more client hints on the next request; Critical-CH makes it retry the first. A server sees both requests and can compare them.

Source: https://mosaicbrowser.com/blog/accept-ch-critical-ch

## Accept-CH and Critical-CH: how a server asks for more

Accept-CH asks Chrome for more client hints on the next request; Critical-CH makes it retry the first. A server sees both requests and can compare them.

![The first request carries less than the second](https://mosaicbrowser.com/covers/accept-ch-critical-ch.webp)

A server that wants more from Chrome than the brand and platform sends an `Accept-CH` header back, naming the client hints it would like. Chrome files that away against the origin and adds the extra `Sec-CH-*` headers from the next request on. So your first visit to a site tells it less than your second. If a hint matters for the very first page, the server can also send `Critical-CH`, and Chrome will throw away the response it just got and ask again with the hint attached. The server sees both requests, a round trip apart, and nothing stops it comparing them.

## What Chrome sends before an Accept-CH header asks

Ask for nothing and you get three: `Sec-CH-UA` (the brand list), `Sec-CH-UA-Mobile` and `Sec-CH-UA-Platform`, on every request to a secure origin. The rest stay home until invited. That covers a lot, from the full version list, platform version, architecture, bitness and device model to the `Sec-CH-Prefers-*` media preferences and the three network hints `RTT`, `Downlink` and `ECT`.

An invitation looks like this, on a navigation response:

```
Accept-CH: Sec-CH-UA-Full-Version-List, Sec-CH-UA-Bitness, Sec-CH-Prefers-Color-Scheme

```

Plain HTTP gets ignored; HTTPS or a local address like 127.0.0.1 counts, and the header has to come with the top-level document. After that the origin keeps getting those hints until somebody clears its site data. A script or image from another host on the same page gets none of them unless the page hands them over with `Permissions-Policy`. RFC 8942 is the HTTP Client Hints specification behind all this. Chromium's own client hints documentation describes how the opt-in is stored.

Mosaic is a desktop app

Email yourself the link and open it on your computer.

[How it works: Machines that exist](https://mosaicbrowser.com/features/real-fingerprints)

## What Critical-CH changes

The cost of `Accept-CH` is that the page which asked never gets the answer. Its own request went out before the opt-in existed. For most hints that does not matter. For a hint that decides what HTML to serve, it does, so a server can mark some hints critical:

```
Accept-CH: Sec-CH-Prefers-Reduced-Motion
Vary: Sec-CH-Prefers-Reduced-Motion
Critical-CH: Sec-CH-Prefers-Reduced-Motion

```

When Chrome receives that response, it works out which hints it would send under the new `Accept-CH`. If one listed in `Critical-CH` would have been sent but was not, it throws the response away and retries the request with the hint attached. The page loads one round trip later, with what it asked for. `Critical-CH` must be a subset of `Accept-CH`, and the hints in it belong in `Vary` too.

## The timing detail a server can see

Look at it from the server's log. A real Chrome hitting a `Critical-CH` page shows up twice: the URL with three hints, then the same URL a round trip later with the critical ones added. Since that is ordinary Chrome behaviour, it makes a decent baseline. A client whose User-Agent says Chrome but which never comes back for the retry is acting like something else, and so is one that volunteers high-entropy hints to an origin that never asked. There is a second comparison too. Plenty of hints have a JavaScript twin, so a page can read the header and the API and check that they match. That one we had to measure on ourselves.

## What we read off our own wire

Our engine can set the network estimate a page reads from `navigator.connection`, through a switch we had deliberately never turned on. The worry was that the `RTT` and `Downlink` headers are built somewhere else in Chromium, so the page and its own request headers could disagree.

Nobody shows those headers back to you, so we wrote a local origin that answers `Accept-CH: rtt, downlink, ect`, with `Critical-CH` naming the same three and a `Permissions-Policy` allowing them. The page reports `navigator.connection` from the window and from a worker, then navigates to a second page whose requests carry the hints. One profile, patched in place, with a control at both ends:

```
control, no switches     JS 50    header [50]
control again            JS 50    header [0, 50]
rtt=300 on the command line
                         JS 300   header [0, 50]

```

In a control, the header carries what the page is told. With the switch on, the page says 300 and the header still says 50. A contradiction like that costs a server one `Accept-CH` line to find, so the switch stays off.

The run also showed the mechanism at work. No hints came on the first request to the origin, since it had not asked yet; test once and you would write down "no headers" and move on. The hinted page then turned up more than once with different numbers, because Chromium keeps revising its network estimate while a page loads, so we kept every request rather than the first. And loopback is too quick to say anything: `rtt` reads 0 and `downlink` pins at its ceiling of 10 until you hold each response back. We held them 400 ms.

## The hints that came from this Mac

The same kind of server, asking for every hint, found something worse. Two unrelated profiles both sent `Sec-CH-Prefers-Color-Scheme: dark`, `Sec-CH-Prefers-Reduced-Motion: reduce` and `Sec-CH-Prefers-Reduced-Transparency: reduce`, because this Mac had those settings on. Reduced motion and reduced transparency are settings few people switch on, and any site that asked got both from every profile on the machine. Those preferences are now drawn per profile, with dark mode at 30%, as explained in [why 30% of our profiles use dark mode](https://mosaicbrowser.com/blog/dark-mode-thirty-percent).

The version hints carry the same two-sided risk. In Tesseract Chrome one switch, `--fingerprint-brand-version`, feeds both the client hint version lists and the User-Agent suffix, so the header a server asked for and the string it got anyway have to agree. A full version with no build number broke that once ([a full Chrome version with no build number](https://mosaicbrowser.com/blog/full-version-without-build-number)), and the GREASE entry has to fit the claimed major too ([Not_A Brand, computed](https://mosaicbrowser.com/blog/not-a-brand-grease)).

## Questions

### What does the Accept-CH header do?

A server sends it back to name the client hints it wants, Sec-CH-UA-Full-Version-List or Sec-CH-Prefers-Color-Scheme for example. Chrome keeps that list for the origin and attaches those hints to later requests there.

### What is the difference between Accept-CH and Critical-CH?

Accept-CH only affects the next request and the ones after it. Critical-CH marks hints the server needed on this one. If Chrome left one of those out but would send it now, it drops the response and retries with it.

### Why doesn't the first request carry client hints?

The site only says what it wants in its response, after that request has gone. Until then Chrome sends the three low-entropy hints: brands, mobile or not, and platform.

### Does Accept-CH work over plain HTTP?

No. Chrome ignores it unless the origin is secure: HTTPS, or a local address such as 127.0.0.1.

## Related

[A full Chrome version with no build number is one no Chrome shipped](https://mosaicbrowser.com/blog/full-version-without-build-number): Our engine reported uaFullVersion as "153". Real Chrome always sends four numbers. Sending the full build moved FingerprintJS's confidence to stock's 0.99.

[Not_A Brand: how Chrome's GREASE brand is computed](https://mosaicbrowser.com/blog/not-a-brand-grease): The Not A Brand entry in Chrome's user agent is computed from the major version by a fixed formula, so a label that doesn't fit the claimed major is a tell.

[Why 30% of our profiles use dark mode](https://mosaicbrowser.com/blog/dark-mode-thirty-percent): The prefers-color-scheme dark mode share in real traffic is far below survey figures. Every profile on our Mac said dark; now each draws dark 30% of the time.

[Browser fingerprint: what a site can read about your machine](https://mosaicbrowser.com/glossary/browser-fingerprint): A browser fingerprint is the set of values a site reads about your browser and computer, which together recognize you without cookies. What goes into one.

[Why every Mac's Chrome says Mac OS X 10_15_7](https://mosaicbrowser.com/blog/mac-os-x-10-15-7): Chrome froze the macOS version in its User-Agent, so every Mac claims 10.15.7. The real one is in Sec-CH-UA-Platform-Version, a hint sites must ask for.

[Why the User-Agent says Windows 10 on Windows 11](https://mosaicbrowser.com/blog/windows-11-user-agent-windows-10): Windows 11 and 10 both send Windows NT 10.0 in the User-Agent. Only the platformVersion client hint, 13 or higher, says Windows 11.

[Why Chrome's User-Agent says 0.0.0](https://mosaicbrowser.com/blog/chrome-user-agent-0-0-0): Chrome zeroes everything after the major, so the User-Agent says 0.0.0 and the build moves to Sec-CH-UA-Full-Version-List. A real build in the UA is a tell.

[Privacy Sandbox is retired, and its APIs are a version tell](https://mosaicbrowser.com/blog/privacy-sandbox-apis-removed): Chrome is removing Topics and Protected Audience release by release, so whether runAdAuction exists tells a page which Chrome you really are.

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