← dictionary
Environment

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.

Category
Environment
API
navigator.userAgentData
Reveals
OS, CPU architecture, browser version
Spoofing
Spoofable, spoof detectable
Since
2020
Liverunning the probe…

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:

cpp
//   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:

cpp
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:

cpp
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:

cpp
// 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:

cpp
// 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:

cpp
// 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

How the probe works

This is the exact source that ran in the panel above — no summary, no drift.

JavaScript
// 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`,
  };
}