The three sources that must agree
Since Chrome 89+ the browser reports its identity in three places, all generated from one internal source - so on a real browser they always match:
- The UA string -
navigator.userAgentand theUser-Agentheader, now frozen/reduced on Chrome (deliberately trimmed and locked down). - Sec-CH-UA headers -
Sec-CH-UA: "Chromium";v="131", "Not_A Brand";v="24", "Google Chrome";v="131"plus platform and mobile flags on every request. - navigator.userAgentData - the JS API, including
getHighEntropyValues(["platform","platformVersion","architecture","model","fullVersionList"]).
A scraper that edits the User-Agent header but leaves Sec-CH-UA and navigator.userAgentData at their real (or missing) values is instantly incoherent - the three sources contradict each other. Python HTTP clients that send a Chrome UA string but no Sec-CH-UA headers at all are an obvious tell, because real Chrome never omits them.
High-entropy hints and the GREASE trap
Low-entropy hints (brand, mobile, platform) ship on every request. High-entropy hints (full version list, architecture, bitness, model, platform version) are sent only when the server asks for them via the Accept-CH response header - so an anti-bot endpoint can request them and watch how the client answers. The values must agree with each other: Sec-CH-UA-Mobile: ?1 (claiming a mobile device) paired with a desktop platform, or Sec-CH-UA-Arch: "arm" with Sec-CH-UA-Bitness: "32" on a claimed Apple Silicon Mac, are contradictions.
The Sec-CH-UA header also contains a GREASE entry - a deliberately fake brand like "Not_A Brand";v="24" that Chrome adds so servers cannot hardcode the brand list, and whose exact text and punctuation vary by Chrome version. Vendors know the real GREASE patterns per version; a hand-built header with the wrong GREASE string, or with the brands in the wrong order, fails the check. navigator.userAgentData.brands must contain the same GREASE entry as the header.
Why this is hard to spoof by hand
Getting UA-CH right means generating one complete, version-accurate identity across all three surfaces at once: the reduced UA string, every Sec-CH-UA header with the correct GREASE and ordering, and a navigator.userAgentData object whose getHighEntropyValues() returns matching platform/arch/model. Change the Chrome major version and all of them have to move together.
This is why brand-switching and UA spoofing are gated behind engine-level tooling in serious anti-detect browsers - the engine regenerates all three from one config so they cannot drift apart. A managed scraping API solves it the same way: it impersonates a real Chrome build end to end rather than editing one header. Patching just the UA string with a Python requests override is the single most common reason a scraper that "looks like Chrome" still gets blocked.
Three channels for one identity
Client hints are unusual in that the browser reports the same identity through three separate channels, which turns any single change into a three-way consistency problem.
- Request headers -
Sec-CH-UA,Sec-CH-UA-Platform, andSec-CH-UA-Mobileare sent automatically; high-entropy hints such as platform version, architecture, and full browser version are sent only when the server requests them viaAccept-CH. - The JavaScript surface -
navigator.userAgentDataexposes the low-entropy brand list synchronously and the high-entropy values throughgetHighEntropyValues(). - The legacy User-Agent string - still sent, still readable from JavaScript, and still encoding platform and version.
All three describe one browser, so all three must agree. The header must match what JavaScript reports; the brand list must name the same major version the User-Agent string does; the platform hint must match navigator.platform and the platform encoded in the User-Agent. A request that rewrites the User-Agent header at the client library level while leaving the client hint headers untouched fails this immediately, and it is a common failure precisely because the two are set in different places.
There is also a structural check independent of the values. The brand list deliberately includes a randomised entry - the so-called GREASE brand - with a nonsensical name, and the version formatting is consistent in arity across entries. A brand list without a GREASE entry, or with versions formatted inconsistently, was assembled by something other than the browser it claims to come from.
Where the header and the runtime part company
The most productive check in this family compares the wire against the runtime: what the server received in headers versus what JavaScript reports on the same page load. These travel through completely different code paths - one through the network stack before any script runs, the other through the JavaScript engine - which is what makes the comparison meaningful.
Several ordinary situations create a gap. HTTP client libraries and proxies commonly set a User-Agent header without knowing that client hints exist, so the headers describe one browser while the runtime describes another. Browser extensions that change the User-Agent string typically reach the JavaScript surface but not every header. And because high-entropy hints require the server to have requested them, a client that volunteers them unprompted, or omits them after they were requested, is not following the negotiation the specification describes.
The same wire-versus-runtime pattern applies beyond client hints. Accept-Language must agree with navigator.languages in both content and order. The Sec-Fetch-* metadata headers must describe the request the page actually made. Header ordering itself is characteristic of the network stack that produced it, and it is set far below anything page JavaScript can influence - which is why it corroborates TLS and HTTP/2 fingerprints so effectively.
Keeping these aligned is a property of the client stack rather than of any individual setting: the same component has to produce the headers, the TLS handshake, and the JavaScript runtime values. A real browser does this by construction. Assembling the same coherence from a request library plus a header dictionary means reproducing every channel consistently, which is the work a managed web data API absorbs on a scraper's behalf.
