User-Agent Client Hints Fingerprinting
The structured replacement for the user-agent string, and the GREASE entry Chromium calls randomized that is really a pure function of your version number.
Published · Updated
- Category
- Environment
- API
navigator.userAgentData- Reveals
- OS, CPU architecture, browser version
- Spoofing
- Spoofable, spoof detectable
- Since
- 2020
The user-agent string is a thirty-year-old lie that every browser tells for
compatibility. User-Agent Client Hints are the structured replacement:
navigator.userAgentData exposes the same facts as fields, splits them into
low- and high-entropy tiers, and lets sites request only what they need.
It is also Chromium-only. Firefox and Safari both declined to implement it, so
the absence of navigator.userAgentData is itself a coarse engine signal — one
of those cases where not shipping an API is the more privacy-preserving choice
and still leaves a mark.
The GREASE entry is not random
Read the brand list and one entry looks like line noise:
Not/A)Brand;v="99", Chromium;v="148"That first entry is GREASE — a deliberately silly value included so that servers are forced to parse the list properly instead of hardcoding assumptions about it. The technique is borrowed from TLS, where it stops middleboxes ossifying the protocol.
Chromium's own source comment describes it this way:
// 3. A randomized string containing GREASE characters to ensure proper
// header parsing, along with an arbitrarily low version to ensure proper
// version checking.The implementation is not randomized. It is a pure function of your major version number:
blink::UserAgentBrandVersion GetGreasedUserAgentBrandVersion(
int seed,
blink::UserAgentBrandVersionType output_version_type) {
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()];Eleven punctuation characters, three possible versions, both indexed by seed.
And the seed is not a random number — the caller passes the Chrome major
version directly:
return GenerateBrandVersionList(major_version_number, brand, brand_version,
output_version_type,
additional_brand_version);Even the ordering is deterministic. ShuffleBrandList picks from a fixed
table of permutations, indexed by the same seed, under a comment that says so
plainly:
// Pick a stable permutation seeded by major version number.
static constexpr std::array<std::array<size_t, 3>, 6> orders{
{{0, 1, 2}, {0, 2, 1}, {1, 0, 2}, {1, 2, 0}, {2, 0, 1}, {2, 1, 0}}};
const std::array<size_t, 3> order = orders[seed % orders.size()];So the entire brand list — the fake brand's punctuation, its version, and the
position of every entry — is computable from one integer. The panel above does
exactly that: it reimplements the algorithm in JavaScript, predicts your list
from your major version alone, and tells you whether the prediction matched.
On the machine this was written on it matched exactly, producing
Not/A)Brand;v="99", Chromium;v="148" from the number 148 and nothing else.
This is worth knowing for two opposite reasons. If you are parsing these headers, GREASE is not noise you need to tolerate — but you must still tolerate it, because the whole point is that the scheme is allowed to change. And if you are counting entropy, the GREASE entry contributes none: it is a re-encoding of the version number you already have.
Version scope: this is the construction used through the M1xx series. Chromium has changed its GREASE scheme before and is free to change it again — that freedom is the feature. If the panel above says the prediction did not match, the scheme has moved on, not your browser.
What the brand count tells you
The list length is more informative than the greasy entry. A branded build carries a product brand alongside Chromium; a raw Chromium build has no product brand to carry, so its list is one shorter. Reading the length distinguishes a branded browser from a plain Chromium without consulting any string that a user-agent override would touch.
The panel reports which case your browser is, and it is a genuine tell: the browser used to write this entry reports two brands, not three.
The legacy string still lies, on purpose
Client Hints did not fix the user-agent string; they route around it. The old string is frozen for compatibility, which produces contradictions that are easy to misread as tampering.
The clearest is macOS, and it is worth getting the mechanism right because half-knowing it produces a confident wrong rebuttal. There are two independent reasons a Mac reports the same decade-old version, not one.
The first is the reduced user agent, on by default since M101, which returns the platform token as a hardcoded literal:
// GetUnifiedPlatform(), under #elif BUILDFLAG(IS_MAC)
return "Macintosh; Intel Mac OS X 10_15_7";The obvious response is to turn that off and get the real version back. It does not work, because the legacy path clamps independently:
// A significant amount of web content breaks if the reported "Mac
// OS X" major version number is greater than 10. Continue to report
// this as 10_15_7, the last dot release for that macOS version.
if (os_major_version > 10) {
os_major_version = 10;
os_minor_version = 15;
os_bugfix_version = 7;
}Since current Chrome requires macOS 13 or newer, every supported system trips
that clamp, so both code paths land on the same string. Note also that the
enterprise UserAgentReduction policy that used to influence this is
deprecated — do not reach for it.
The Intel is the same story: a compile-time literal on both paths, never a
question anyone asks the CPU. The architecture hint does ask, which is why
these two surfaces disagree. Measured on this machine:
| Source | Value |
|---|---|
navigator.userAgent |
Macintosh; Intel Mac OS X 10_15_7 |
platformVersion |
26.6.1 |
architecture |
arm |
macOS 26.6.1 on Apple Silicon, describing itself as Intel macOS 10.15.7. Both statements are official; one is a frozen literal kept so old server-side sniffing does not break, the other is the real value.
If you write consistency checks, this is the trap: a browser reporting Intel in
the UA and arm in the hints is not lying to you. It is a stock Mac. Treating
that pair as a contradiction flags every Apple Silicon user on the web.
Other platforms, other contradictions
Each of these is verified against Chromium 152 source. They are ordinary platform behaviour, and each one will burn a naive consistency check.
| Platform | The surprise |
|---|---|
| macOS | UA says Intel Mac OS X 10_15_7; hints report the real version and arm. Under Rosetta, architecture still reports arm — it is the CPU type, not the build's. |
| Android | architecture is the empty string, not a value. So is bitness. Three cases hardcode "x86" instead — desktop-site mode, desktop-Android builds, and XR devices — and on all three the device is usually ARM anyway, so the value is a constant rather than a reading. |
| Windows on ARM | wow64 is false on every build: native ARM64, emulated x64, emulated x86 alike. It is not an ARM carve-out — the bit means only "x86 process on an AMD64 host". wow64: false therefore does not mean "not emulated"; on Windows-on-ARM the emulation shows up in architecture, never here. |
| Windows | platformVersion is not a Windows version. It is the Windows.Foundation.UniversalApiContract number, read live from the registry. Windows 11 never reports 11. Recovering the marketing release needs a contract-to-release table that Chromium does not contain. |
The Windows one has a trap worth stating separately: if the registry read
fails, Chrome silently substitutes the highest contract version it knows about
— 19 in current source. So 19.0.0 means either "contract 19" or "could not
read it", and nothing in the value distinguishes them.
Android's empty architecture is also on the clock: there is an open bug in
the tree to start reporting the real CPU type, so treat it as current behaviour
rather than a fixed property.
What Client Hints are worth as a fingerprinting signal
The low-entropy hints — brands, mobile, platform — are sent on every request
and carry little on their own; they are roughly the user-agent's information
restated. The high-entropy set (platformVersion, architecture, bitness,
model, uaFullVersion) is where the bits are, and it requires an explicit
getHighEntropyValues() call, which is the design working as intended: a site
that wants your exact OS build has to ask, in code, where it can be observed
asking.
Which of those actually narrows you down depends on how common your particular OS build and CPU are among everyone else being measured — a population this page cannot see and does not estimate.
Spoofing
navigator.userAgentData is an ordinary JavaScript object, so a page's own view
of it can be replaced by anything running script in that realm. What makes a
careless override visible is everything above: the GREASE entry has to be the
correct deterministic function of the claimed major version, the list length has
to match the claimed brand, the ordering has to match the permutation table, and
the high-entropy values have to agree with the frozen legacy string in the
specific ways the platform actually produces — including the Intel-versus-arm
pairing that looks wrong and is correct.
That is the same argument as the gzip OS byte: values that are cheap to read individually get expensive to forge together.
References
- Chromium, public source:
components/embedder_support/user_agent_utils.cc—GetGreasedUserAgentBrandVersion,GetRandomOrder,ShuffleBrandList, and the Mac platform literal. All code quoted above is from that file. - User-Agent Client Hints specification, including the arbitrary brands section that the implementation cites.
- MDN:
NavigatorUAData,getHighEntropyValues().
How the probe works
This is the exact source that ran in the panel above — no summary, no drift.
// Reimplements Chromium's GREASE brand generation in JavaScript and predicts
// the reader's own brand list from nothing but their major version number. If
// the prediction matches, the "randomised" entry demonstrably isn't random.
//
// Ported from components/embedder_support/user_agent_utils.cc
// (GetGreasedUserAgentBrandVersion, GetRandomOrder, ShuffleBrandList).
// Version-scoped: this is the scheme as of the M1xx series. Chromium has
// changed its GREASE construction before and may again.
const GREASE_CHARS = [" ", "(", ":", "-", ".", "/", ")", ";", "=", "?", "_"];
const GREASE_VERSIONS = ["8", "99", "24"];
const ORDERS_3 = [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]];
// shuffled[order[i]] = list[i] — order maps source index to destination.
function shuffle(list, seed) {
const order =
list.length === 2
? [seed % 2, (seed + 1) % 2]
: ORDERS_3[seed % ORDERS_3.length];
const out = new Array(list.length);
order.forEach((dest, i) => {
out[dest] = list[i];
});
return out;
}
function greaseEntry(seed) {
return {
brand: `Not${GREASE_CHARS[seed % GREASE_CHARS.length]}A${
GREASE_CHARS[(seed + 1) % GREASE_CHARS.length]
}Brand`,
version: GREASE_VERSIONS[seed % GREASE_VERSIONS.length],
};
}
// Branded builds carry a product brand alongside Chromium; raw Chromium does
// not, so the list length differs. Predict both and see which the browser matches.
function predict(seed, branded) {
const list = [greaseEntry(seed), { brand: "Chromium", version: String(seed) }];
if (branded) list.push({ brand: "Google Chrome", version: String(seed) });
return shuffle(list, seed);
}
const norm = (arr) => JSON.stringify(arr.map((b) => ({ brand: b.brand, version: b.version })));
export async function probe() {
const uaData = navigator.userAgentData;
if (!uaData) {
throw new Error(
"navigator.userAgentData is not implemented — User-Agent Client Hints are Chromium-only",
);
}
const brands = uaData.brands ?? [];
const chromium = brands.find((b) => /^Chromium$/i.test(b.brand));
const seed = chromium ? Number(chromium.version) : NaN;
const value = {
"Brands reported": brands.map((b) => `${b.brand} ${b.version}`).join(" · ") || "none",
"Brand count": `${brands.length} (${brands.length >= 3 ? "branded build" : "raw Chromium — no product brand"})`,
};
if (Number.isFinite(seed)) {
const asChromium = predict(seed, false);
const asBranded = predict(seed, true);
const actual = norm(brands);
const matched =
actual === norm(asChromium) ? "raw Chromium" : actual === norm(asBranded) ? "branded" : null;
value["Seed (Chromium major version)"] = seed;
value["GREASE brand, computed from seed alone"] = greaseEntry(seed).brand;
value["GREASE version, computed from seed alone"] = greaseEntry(seed).version;
value["Predicted full list"] = (matched === "branded" ? asBranded : asChromium)
.map((b) => `${b.brand} ${b.version}`)
.join(" · ");
value["Prediction matches reality"] = matched
? `yes — exactly, as ${matched}`
: "no — the GREASE scheme may have changed since this was written";
}
// The high-entropy hints are gated behind an explicit request; nothing here
// prompts the user, these are the freely-available ones.
try {
const hi = await uaData.getHighEntropyValues([
"architecture",
"bitness",
"model",
"platformVersion",
"uaFullVersion",
"wow64",
]);
value["platform"] = uaData.platform;
value["platformVersion"] = hi.platformVersion || "(empty)";
value["architecture"] = hi.architecture === "" ? '"" (empty)' : hi.architecture;
value["bitness"] = hi.bitness || "(empty)";
value["wow64"] = String(hi.wow64);
value["model"] = hi.model === "" ? '"" (empty)' : hi.model;
value["mobile"] = String(uaData.mobile);
} catch (e) {
value["High-entropy hints"] = `unavailable: ${e && e.message ? e.message : e}`;
}
// The legacy string, for comparison against the structured values above.
value["Legacy navigator.userAgent"] = navigator.userAgent;
const facts = {};
if (value["platform"]) {
facts["Operating system"] = value["platformVersion"] && value["platformVersion"] !== "(empty)"
? `${value["platform"]} ${value["platformVersion"]}`
: String(value["platform"]);
}
if (value["architecture"]) facts["Processor"] = String(value["architecture"]).replace(/^""$/, "not reported");
if (Number.isFinite(seed)) {
facts["Browser"] = `${brands.length >= 3 ? "branded build" : "raw Chromium"}, major ${seed}`;
}
return {
facts,
value,
label: Number.isFinite(seed)
? `${greaseEntry(seed).brand} — predicted from major ${seed}`
: `${brands.length} brands`,
};
}