You set a Windows User-Agent on a Playwright context because a target serves a lighter page to Windows. The run is on a Mac in CI. The site now sees this:
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) ... Chrome/152.0.0.0 Safari/537.36
navigator.platform: "MacIntel"Two answers to one question, in the same request. Nobody wrote a clever test to catch it: the page asked for the platform twice, the way pages have for years, and got two different values.
This happens so easily because Chrome deliberately stopped putting the truth in the User-Agent string and put it somewhere else.
Chrome Froze the User-Agent String
Under User-Agent Reduction, Chrome no longer varies most of the UA; the platform token is a compile-time constant. From components/embedder_support/user_agent_utils.cc:
#elif BUILDFLAG(IS_MAC)
return "Macintosh; Intel Mac OS X 10_15_7";
#elif BUILDFLAG(IS_WIN)
return "Windows NT 10.0; Win64; x64";
...
#elif BUILDFLAG(IS_LINUX)
return "X11; Linux x86_64";
Android collapses to Linux; Android 10; K, a fixed OS version and a device model of literally K. The Chrome version reduces to Chrome/<major>.0.0.0.
The UA string is now a frozen fiction on purpose, and you can watch it lie about the machine it runs on — real Chrome 152 on an Apple Silicon Mac running macOS 26.2:
User-Agent: ... Intel Mac OS X 10_15_7 ... Chrome/152.0.0.0 ...
Sec-CH-UA-Arch: "arm"
Sec-CH-UA-Platform-Version: "26.2.0"
Sec-CH-UA-Full-Version-List: "Google Chrome";v="152.0.7977.76", ...
Intel versus arm. 10_15_7 versus 26.2.0. 152.0.0.0 versus 152.0.7977.76. None of that is a spoof; it is the designed behaviour. The UA is a compatibility relic; the hints carry the facts.
The Client Hints Model
User-Agent Client Hints split the same information into two tiers.
Low entropy, sent by default. Three headers go out on every request with no opt-in, cheap enough that the UA already gave them away:
sec-ch-ua: "Chromium";v="152", "Not?A_Brand";v="24", "Google Chrome";v="152"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
Those are structured field values: ?0 is a boolean false, the strings are quoted. A hand-rolled client sending Sec-CH-UA-Mobile: 0 or an unquoted platform is immediately odd.
High entropy, requested explicitly. Everything else arrives only if the server asks for it with an Accept-CH response header: Sec-CH-UA-Full-Version-List, Sec-CH-UA-Arch, Sec-CH-UA-Bitness, Sec-CH-UA-Model, Sec-CH-UA-Platform-Version, Sec-CH-UA-Form-Factors, Sec-CH-UA-WoW64, and the deprecated Sec-CH-UA-Full-Version.
Point a real Chrome at a server that sends Accept-CH for the full set and the second request carries all of them:
sec-ch-ua-arch: "arm"
sec-ch-ua-bitness: "64"
sec-ch-ua-model: ""
sec-ch-ua-platform-version: "26.2.0"
sec-ch-ua-wow64: ?0
sec-ch-ua-form-factors: "Desktop"
sec-ch-ua-full-version-list: "Chromium";v="152.0.7977.76", "Not?A_Brand";v="24.0.0.0", "Google Chrome";v="152.0.7977.76"
Note sec-ch-ua-model: "", on desktop the model is an empty string, not an absent header. A client that omits it or invents a device name has answered differently from the browser it claims to be.
Accept-CH, Critical-CH and the Retry
Accept-CH persists into the browser's Accept-CH cache for the origin, and a new Accept-CH header replaces the previous set rather than adding to it. That leaves a gap: on a first visit nothing is cached, so the very first navigation, the one that matters if you are branching server-side, arrives without high-entropy hints.
Critical-CH closes it by triggering a restart. The spec is precise about when: "A restart will only occur when a hint in the Accept-CH header is both not in the Accept-CH cache and in the Critical-CH header." The browser then "retries the entire navigation (including any prior redirects)," and the cache plus a restart timestamp stop it from looping.
Serving Accept-CH for the high-entropy set plus Critical-CH: Sec-CH-UA-Full-Version-List to a fresh Chrome produced two requests for the same path: the first with only the three default hints, the second with all of them. So a site can have full hints on the first byte of HTML it serves, and a client that ignores response headers answers a question the browser would have answered twice.
An ACCEPT_CH HTTP/2 and HTTP/3 frame also carries the request earlier, at connection setup. Those hints are not written to the Accept-CH cache but merged into the connection's hint set, and there "MUST be only one ACCEPT_CH frame per-connection."
The JavaScript Side
The same data reaches script through navigator.userAgentData, wired into Blink through the NavigatorUA mixin exactly as webdriver comes in through NavigatorAutomationInformation. Low-entropy values are properties (brands, mobile, platform); high-entropy values come back from a promise:
await navigator.userAgentData.getHighEntropyValues([
"architecture", "bitness", "formFactors", "fullVersionList",
"model", "platformVersion", "uaFullVersion", "wow64",
]);Those eight strings are the accepted hint names, and the resolved object also includes the low-entropy trio. navigator.userAgentData is a Chromium-only surface — its absence in a browser whose UA claims to be Chrome is a contradiction, and its presence in one claiming to be Safari is another.
Why the Brand List Has Garbage In It
Sec-CH-UA never contains a single brand. Chromium generates a deliberately ugly extra entry:
const std::vector<std::string> greasey_chars = {" ", "(", ":", "-", ".", "/",
")", ";", "=", "?", "_"};
const std::vector<std::string> greased_versions = {"8", "99", "24"};
greasey_brand =
base::StrCat({"Not", greasey_chars[(seed) % greasey_chars.size()], "A",
greasey_chars[(seed + 1) % greasey_chars.size()], "Brand"});
greasey_version = greased_versions[seed % greased_versions.size()];The brand list is then run through ShuffleBrandList(brand_version_list, seed). That is why the run above produced "Not?A_Brand";v="24" between Chromium and Google Chrome, and why the next machine produces a different punctuation pair in a different position.
This is GREASE, and the spec requires it: user agents "MUST include more than a single value in brands, where one of these values is an arbitrary value," and "the value order in brands MUST change over time to prevent receivers from relying on certain values being in certain locations." It keeps server-side parsers honest: a parser that assumes brands[0] is the real browser breaks on purpose, early, rather than ossifying.
For a client author, GREASE is a trap in the other direction: a hardcoded "Not_A Brand";v="8" copied from a blog post is fixed where the browser's varies, and identical across your fleet.
Four Declarations, Four Vocabularies
Here is the coherence problem exactly: one browser, one machine, four declarations of the same OS:

navigator.platform returns "MacIntel" on Mac and "Win32" on Windows, matching what Safari and Mozilla historically returned; on Unix-likes Blink builds it from uname(), a real syscall, producing Linux x86_64.
navigator.appVersion is the one that cannot disagree, because Blink derives it: "Version is everything in the user agent string past the Mozilla/ prefix." Anything that changes the UA changes appVersion with it, making it a free integrity check rather than a value to maintain.
Sec-CH-UA-Platform-Version has its own trap: on Linux, GetPlatformVersion() returns the empty string, which is correct for Linux and wrong for macOS or Windows.
Now look at what a UA override propagates to. Playwright derives Client Hints metadata from the UA string by regex, in calculateUserAgentMetadata:
const metadata: Protocol.Emulation.UserAgentMetadata = {
mobile: !!options.isMobile,
model: '',
architecture: 'x86',
platform: 'Windows',
platformVersion: '',
};
const macOSMatch = ua.match(/Mac OS X (\d+(_\d+)?(_\d+)?)/);That is more than most tools do — set a macOS UA and Sec-CH-UA-Platform follows. Note the defaults: a UA matching none of its patterns yields platform: 'Windows', architecture: 'x86'. And what it does not set: brands and fullVersionList are absent, and CDP's separate platform parameter on Emulation.setUserAgentOverride, documented as "The platform navigator.platform should return", is never passed, so navigator.platform keeps the host machine's value. That is the failure from the top of this post, a default rather than a mistake anyone made.
Puppeteer exposes all three knobs, setUserAgent({ userAgent, userAgentMetadata, platform }): the honest shape of the problem is three layers, three arguments, coherence the caller's job.
Default headless Chrome does not manage it either: its UA product token is HeadlessChrome/152.0.0.0 while its brand list says Chromium and Google Chrome, with no headless brand anywhere. The browser contradicts itself out of the box.
A Consistency Report You Can Run
This reads all four declarations from your own automation and prints agreements and disagreements: headers from the request Chrome sent, the JS side from the page.
import re
from playwright.sync_api import sync_playwright
JS = """async () => {
const uad = navigator.userAgentData;
const hints = uad ? await uad.getHighEntropyValues([
"architecture", "bitness", "formFactors", "fullVersionList",
"model", "platformVersion", "uaFullVersion", "wow64",
]) : null;
return {
ua: navigator.userAgent,
platform: navigator.platform,
appVersion: navigator.appVersion,
uad: uad && {brands: uad.brands, mobile: uad.mobile, platform: uad.platform},
hints: hints,
};
}"""
PLATFORM_TO_NAVIGATOR = {
"macOS": "MacIntel", "Windows": "Win32",
"Linux": "Linux", "Android": "Linux",
}
def check(headers, js):
hints, uad = js.get("hints") or {}, js.get("uad") or {}
yield (headers.get("user-agent") == js["ua"],
"UA header == navigator.userAgent", js["ua"])
yield (js["appVersion"] == js["ua"].split("/", 1)[1],
"navigator.appVersion == UA minus 'Mozilla/'", js["appVersion"])
hdr_platform = (headers.get("sec-ch-ua-platform") or "").strip('"')
yield (hdr_platform == uad.get("platform"),
"Sec-CH-UA-Platform == userAgentData.platform",
f"{hdr_platform!r} vs {uad.get('platform')!r}")
expected = PLATFORM_TO_NAVIGATOR.get(uad.get("platform"))
yield (expected is not None and js["platform"].startswith(expected),
"userAgentData.platform matches navigator.platform vocabulary",
f"{uad.get('platform')!r} -> expected {expected!r}, got {js['platform']!r}")
yield (headers.get("sec-ch-ua-mobile") == ("?1" if uad.get("mobile") else "?0"),
"Sec-CH-UA-Mobile == userAgentData.mobile",
f"{headers.get('sec-ch-ua-mobile')!r} vs {uad.get('mobile')!r}")
major = re.search(r"Chrome/(\d+)", js["ua"])
full = hints.get("uaFullVersion", "")
yield (bool(major) and full.startswith(major.group(1) + "."),
"UA major version == uaFullVersion major",
f"{major and major.group(1)!r} vs {full!r}")
hdr_brands = set(re.findall(r'"([^"]+)";v="[^"]+"', headers.get("sec-ch-ua", "")))
js_brands = {b["brand"] for b in uad.get("brands", [])}
yield (hdr_brands == js_brands, "Sec-CH-UA brands == userAgentData.brands",
f"{sorted(hdr_brands)} vs {sorted(js_brands)}")
yield (("HeadlessChrome" in js["ua"]) == any("Headless" in b for b in js_brands),
"UA product token agrees with brand list",
f"UA headless={'HeadlessChrome' in js['ua']}")
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
response = page.goto("https://example.com")
headers = response.request.all_headers()
for ok, label, detail in check(headers, page.evaluate(JS)):
print(f"{'PASS' if ok else 'FAIL'} {label:52} | {detail}")
browser.close()Use all_headers(), not request.headers, which omits security headers, sec-ch-ua* is exactly what you need. Run it unmodified for a baseline, then with every override you plan to ship. Every FAIL is a question a page can ask and get two answers to.
Against the browser measured for this post — Chrome 152 in new headless mode, no overrides, the only failing line was the last: the HeadlessChrome product token against a brand list with no headless brand.
Delegating Hints to Third Parties
One trap for measurement and CDN setups: Accept-CH opt-in applies to same-origin subresources only. Sending hints to a third-party origin requires explicit first-party delegation through Permissions Policy, the feature token derived from the header name by lowercasing it and dropping the sec- prefix:
Permissions-Policy: ch-ua-platform-version=(self "downloads.example.com"),
ch-dpr=(self "cdn.provider" "img.example.com")
A Delegate-CH metadata tag feeds the same allowlist filtering from markup, and the UA-CH spec defines "ch-ua-high-entropy-values" as the policy-controlled feature gating getHighEntropyValues() itself, default allowlist *. If a third party sees fewer hints than your own origin, delegation is where to look before you suspect the browser.
Mistakes That Waste Time
- Overriding the UA and stopping there. In Playwright, that gets you the header and the derived hints, and leaves
navigator.platformreporting the host OS. Three of four layers agreeing is not agreement. - Copying a GREASE brand from a blog post. It is generated per-install from a shuffled list; a fixed value repeated across a fleet is the opposite of what GREASE is for.
- Sending high-entropy hints unprompted. A client that volunteers
Sec-CH-UA-Archto an origin that never sentAccept-CHis doing something no browser does. - Trusting UA-derived OS values. The macOS UA says
10_15_7andIntelon hardware running macOS 26 on ARM; anything you build on those tokens is built on a constant.
Wrapping Up
Chrome froze the User-Agent string and moved the real values into Client Hints, which turned one declaration of identity into four, the UA header, the Sec-CH-UA-* set, navigator.userAgentData, and the legacy navigator.platform and appVersion pair. They use four different vocabularies for the same operating system, and only appVersion is derived from another.
Two changes worth making: run the consistency report against your own automation before you add any override, and treat a UA change as something you propagate to every layer answering that question, or do not make at all. An unmodified browser telling the truth in four places is a quieter session than a modified one telling two stories.



