🍱 Lunchbox Hands

user-agent

Why Every User-Agent String Looks the Same Now: UA Reduction and Client Hints

Why your analytics show Windows NT 10.0 and Mac OS X 10_15_7 for everyone, which UA tokens Chrome and Safari deliberately froze, and how User-Agent Client Hints replace the detail — in the browsers that ship them.

Open your analytics and every Windows visitor is on “Windows NT 10.0” — including the ones you know run Windows 11. Every Mac visitor is on “Mac OS X 10.15.7,” a version Apple stopped shipping in 2020. Every Chrome install is version something-point-zero-point-zero-point-zero. None of this is a bug in your dashboard. The User-Agent string was deliberately frozen: the major browsers now send fixed, partly false platform tokens on purpose, and the real details moved — in Chromium only — to a separate mechanism called User-Agent Client Hints.

Understanding what froze, why, and where the truth went now takes two mental models instead of one, because the browsers split on the answer.

Why every browser claims to be Mozilla in the first place

The UA string was already a stack of compatibility lies before anyone froze it. Every mainstream browser’s UA starts with Mozilla/5.0 — MDN’s dry summary is that this is “the general token that says that the browser is Mozilla-compatible,” which “for historical reasons, almost every browser today sends.”

The history in one breath: mid-90s servers sent the good frames-and-JavaScript version of pages only to Netscape, whose token was Mozilla. So Internet Explorer shipped claiming Mozilla/4.0 (compatible; MSIE ...) to get the good pages. Sites then sniffed for Gecko, so Apple’s new WebKit engine declared itself (KHTML, like Gecko). Sites sniffed for Safari, so Chrome — built on WebKit — appended both Chrome/... and Safari/537.36. Each token is a fossil of some sniffing scheme it was built to defeat, which is why a modern Chrome-on-Windows UA reads like four browsers wearing a trench coat:

Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
  (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36

The lesson browser vendors took from thirty years of this: any detail you put in the UA string gets parsed badly, then breaks things when it changes. The freeze is the logical endpoint.

What Chrome froze, exactly

Chromium’s UA reduction plan rolled out in phases and completed at Chrome 113 (mid-2023). Since then, every Chromium browser — Chrome, Edge, Opera, Brave — sends a reduced UA built from a fixed template. The frozen values:

TokenFrozen valueThe lie
Windows platformWindows NT 10.0; Win64; x64Sent on Windows 11 too — there is no Windows 11 UA string
macOS platformMacintosh; Intel Mac OS X 10_15_7Sent on every macOS since, and on Apple Silicon (“Intel”)
Linux platformX11; Linux x86_64Sent regardless of architecture
Android versionAndroid 10Sent on every Android version
Android device modelKEvery phone and tablet is a device called “K”
Browser version143.0.0.0Real major version, but minor/build/patch frozen at 0.0.0

So a Pixel 9 on Android 16 running Chrome introduces itself as (Linux; Android 10; K), and a Windows 11 gaming rig is indistinguishable from a 2016 Windows 10 laptop. The stated motivation is fingerprinting reduction: the UA string was historically worth about 10 bits of identifying entropy all by itself, handed to every server and every script unconditionally. (For how those bits combine with everything else your browser volunteers, see browser fingerprinting explained.)

Safari froze macOS first — for a funnier reason

The 10_15_7 cap isn’t even Chrome’s invention. When macOS Big Sur bumped the version to 11, sites doing naive string comparison decided 11_0 was older than 10_15 — “10” sorts after “1” — and served upgrade warnings to brand-new Macs. Safari’s fix, shipped in Safari 15, was to report Intel Mac OS X 10_15_7 forever, on every macOS release since. Chromium adopted the same cap in Chrome 90, and Firefox caps it at 10.15 too (Bugzilla #1679929).

That’s the pattern worth internalizing: parts of the UA string are frozen for privacy, parts are frozen because telling the truth broke the web, and from the outside you can’t tell which is which. Either way, the string no longer means what it says.

What this breaks in practice

Concretely, if you’re doing any of the following, you’re getting wrong answers, silently:

  • OS version detection. Windows 10 vs 11 and any macOS version distinction are simply gone from the UA. Your analytics OS-version chart is fiction below the OS-family level.
  • Patch-level checks. Chrome/143.0.0.0 carries a real major version and three fake zeros. Security tooling that gates on full build numbers reads frozen data.
  • Device segmentation on Android. Every Chromium-on-Android visitor is model “K”. Device-model reports collapsed to one row when phase 6 landed.
  • Old regexes. A UA parser last updated years ago will confidently report Android 10 market share exploding and Windows 11 at zero.

Client Hints: the replacement (where it exists)

Chromium’s answer is User-Agent Client Hints: the same information, restructured so that the cheap parts are ambient and the identifying parts must be requested.

Low-entropy hints are sent by default with every request, no negotiation needed:

Sec-CH-UA: "Google Chrome";v="143", "Chromium";v="143", "Not A(Brand";v="24"
Sec-CH-UA-Mobile: ?0
Sec-CH-UA-Platform: "Windows"

That’s brand + major version, a mobile boolean, and the OS family — roughly what the reduced UA string still tells you, in a parseable format.

High-entropy hints — OS version, CPU architecture, bitness, device model, full browser version list — must be asked for. A server requests them with an Accept-CH response header, and the browser includes them on subsequent requests at its discretion:

Accept-CH: Sec-CH-UA-Platform-Version, Sec-CH-UA-Arch, Sec-CH-UA-Full-Version-List

In JavaScript, the same split shows up on navigator.userAgentData: brands, mobile, and platform are synchronously readable, and the rest goes through an async request:

const details = await navigator.userAgentData.getHighEntropyValues([
  'platformVersion', 'architecture', 'bitness', 'model', 'fullVersionList',
]);
// On Windows 11: platformVersion is "13.0.0" or higher —
// the OS-version truth the UA string no longer carries

So the Windows 10-vs-11 question is answerable in Chrome — but only by asking, and the request is observable. That’s the design: identifying detail becomes an explicit, auditable transaction instead of an ambient broadcast.

And notice "Not A(Brand";v="24" in the example above. That’s not a glitch — the spec requires browsers to include an arbitrary, deliberately weird entry in the brand list (the technique is called GREASE). Punctuation, fake version and all, it exists so nobody can parse Sec-CH-UA with a lazy regex or hardcode the list’s order — the exact mistakes that ossified the old UA string. Treat the brand list as a set you search, never a string you match.

The catch: two of the three engines never shipped it

Here’s the part that makes “just migrate to Client Hints” wrong. Firefox and Safari do not send Sec-CH-UA headers and do not implement navigator.userAgentData — as of 2026, User-Agent Client Hints remain Chromium-only. MDN marks the API “limited availability,” explicitly not Baseline. Mozilla’s official standards position started at “harmful” and has only softened to neutral (mozilla/standards-positions #579) without any implementation shipping; WebKit likewise tracks the proposal (WebKit/standards-positions #70) and hasn’t shipped it.

Which leaves the web in a permanently split state:

  • Chromium: frozen UA string, real data via Client Hints.
  • Firefox and Safari: frozen-ish UA string (macOS capped, detail reduced), no Client Hints — the UA string is all you get.

The UA string is therefore frozen but immortal. It can’t be removed, because for two engines it’s the only channel; it can’t be trusted, because for all three it’s partly fiction. Any real-world detection code needs both paths: read navigator.userAgentData / Sec-CH-UA-* when present, fall back to UA-string parsing when not — and that fallback is permanent, not transitional.

What the UA string tells you now — and where the truth went

What the UA used to tell youWhat it says now (Chromium)Where to get it instead
Windows versionWindows NT 10.0 alwaysSec-CH-UA-Platform-Version / getHighEntropyValues(['platformVersion']) — Chromium only
macOS version10_15_7 always (Safari too)High-entropy platformVersion in Chromium; nowhere in Safari
Full browser version143.0.0.0 — major real, rest zerosSec-CH-UA-Full-Version-List / fullVersionList
Android version + device modelAndroid 10; K alwaysplatformVersion + Sec-CH-UA-Model
CPU architectureWin64; x64 (frozen), “Intel” on ARM MacsSec-CH-UA-Arch + Sec-CH-UA-Bitness
Browser family + major versionStill realAlso in Sec-CH-UA (parse as a set — GREASE)
Mobile vs desktopStill inferableSec-CH-UA-Mobile: ?1 / ?0

What to actually do

In page code: detect features, not browsers. Nine times out of ten the browser name was a proxy for “does this API exist,” and if ('showOpenFilePicker' in window) answers that directly, forever, with no parsing. The UA freeze only hurts code that asked who instead of whether.

Where you genuinely need identity — analytics, logs, support tooling — parse with a maintained library, not a regex. Current parsers know the frozen tokens and won’t report a device called “K” as a device called K. Our user-agent parser runs one (ua-parser-js) locally in your browser: paste any UA from your logs and it breaks out browser, engine, OS, device type/vendor/model, and CPU architecture. Click “Use my current UA” and you can watch this whole article happen — on a Mac you’ll see your own browser swear it’s on macOS 10.15.7.

Calibrate what the string is still worth. The UA is now one blunt signal among many; the browser fingerprint test shows everything else your browser answers alongside it, which is useful perspective on why vendors decided the UA’s detail wasn’t worth its cost.

The one-line summary: the User-Agent string didn’t die — it retired in place. It still shows up everywhere, says the same four things about everyone, and the real information now lives behind an API that a third of the browser market politely declined to build.