Skip to content
On this page

User-Agent Client Hints (UA-CH) are a set of structured HTTP headers plus a matching JavaScript API that report the same browser and operating-system facts the old User-Agent text string used to carry. On every request Chrome sends Sec-CH-UA, Sec-CH-UA-Platform, and Sec-CH-UA-Mobile; it exposes the same data to JavaScript through navigator.userAgentData; and it answers requests for more detailed "high-entropy" hints (Sec-CH-UA-Platform-Version, Sec-CH-UA-Arch, Sec-CH-UA-Full-Version-List, Sec-CH-UA-Model) - "entropy" here meaning how much a value narrows down who you are. Anti-bot systems fingerprint scrapers by checking whether all three sources - the legacy UA string, the Sec-CH-UA headers, and the JS API - tell exactly the same story.

Quick facts

HeadersSec-CH-UA, Sec-CH-UA-Platform, Sec-CH-UA-Mobile (low-entropy, always sent)
On requestSec-CH-UA-Arch, -Bitness, -Model, -Platform-Version, -Full-Version-List
JS mirrornavigator.userAgentData.getHighEntropyValues()
Core checkUA string == Sec-CH-UA headers == userAgentData (all must agree)
GREASESec-CH-UA includes a deliberately random brand entry browsers must tolerate

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.userAgent and the User-Agent header, 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.

  1. Request headers - Sec-CH-UA, Sec-CH-UA-Platform, and Sec-CH-UA-Mobile are sent automatically; high-entropy hints such as platform version, architecture, and full browser version are sent only when the server requests them via Accept-CH.
  2. The JavaScript surface - navigator.userAgentData exposes the low-entropy brand list synchronously and the high-entropy values through getHighEntropyValues().
  3. 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.

Code example

javascript
// Server-side coherence check an anti-bot endpoint runs
// 1) ask for high-entropy hints
res.setHeader('Accept-CH',
  'Sec-CH-UA-Platform-Version, Sec-CH-UA-Arch, Sec-CH-UA-Full-Version-List, Sec-CH-UA-Model');

// 2) on the next request, compare all sources
function coherent(req) {
  const ua   = req.headers['user-agent'] || '';
  const chUA = req.headers['sec-ch-ua'] || '';        // '"Chromium";v="131", "Not_A Brand";v="24"...'
  const plat = req.headers['sec-ch-ua-platform'] || ''; // '"Windows"'
  const mob  = req.headers['sec-ch-ua-mobile'] || '';   // '?0'

  if (ua.includes('Chrome') && !chUA) return false;     // Chrome never omits Sec-CH-UA
  if (ua.includes('Windows') && !plat.includes('Windows')) return false;
  if (mob === '?1' && plat.includes('Windows')) return false; // mobile flag vs desktop OS
  const major = (ua.match(/Chrome\/(\d+)/) || [])[1];
  if (major && !chUA.includes('v="' + major + '"')) return false; // version drift
  return true;
}

Next in Environment and display signals · 4 of 7

Where locale and network have to agree.

What Is a Timezone / IP Mismatch?

Related terms

Concept map

Concept map

How Client Hints Fingerprinting connects

The terms most directly tied to this one. Hover a node to see its neighbours, click to preview, drag to rearrange.

0 terms · 0 connections
You are here · Anti-Bot
Building map…

Frequently asked questions

What is the difference between the User-Agent string and Client Hints?

The UA string is one freeform line of text that Chrome is freezing and reducing to limit passive fingerprinting. Client Hints carry the same facts in a structured, opt-in form - as headers plus a JS API. The key detection point is that they must agree: the UA string, the Sec-CH-UA headers, and navigator.userAgentData all derive from one source on a real browser, so any mismatch stands out.

Do I need to send Sec-CH-UA headers from a Python scraper?

If you send a Chrome User-Agent, yes - real Chrome always sends Sec-CH-UA, Sec-CH-UA-Platform, and Sec-CH-UA-Mobile, so their absence flags you. Better still, impersonate a real Chrome build end to end (a TLS-impersonating client or a real browser) instead of hand-assembling headers, because the GREASE token and brand ordering are easy to get wrong.

What is GREASE in Sec-CH-UA?

GREASE is a deliberately fake brand entry (e.g. "Not_A Brand";v="24") that browsers include so servers cannot hardcode the brand list. Its exact text, version, and position vary by Chrome version. Anti-bot vendors know the real patterns, so a wrong or missing GREASE entry is a clear spoofing tell.

Why is changing the User-Agent header alone no longer sufficient?

Because modern browsers report the same identity through several channels at once. The Sec-CH-UA client hint headers, the navigator.userAgentData JavaScript surface, and the legacy User-Agent string must all describe one browser. Changing only the User-Agent header leaves the client hints and the JavaScript surface describing the original client, which is a direct contradiction on a single page load.

Last updated: 2026-07-30 · Facts last verified: 2026-07-30