Signal

User-Agent data

Calls navigator.userAgentData.getHighEntropyValues() to retrieve the four Chromium-only high-entropy UA Client Hints: architecture, bitness, model, and platformVersion. Safari and Firefox return absent. Raw strings are hashed verbatim with no normalisation.

Reviewed

Tier 1 engine

What it measures

The signal captures the four high-entropy User-Agent Client Hints fields that Chromium exposes only on explicit `getHighEntropyValues()` request: `architecture` (the OS-claimed CPU family, e.g. `'arm'` or `'x86'`), `bitness` (process bitness, `'64'` or `'32'`), `model` (device-marketed name, typically empty on desktop and populated on Android as e.g. `'Pixel 7'`), and `platformVersion` (OS minor version number, e.g. `'14.6.1'`).

The four strings are hashed verbatim: no normalisation, no lowercasing, no bucketing. Chromium returns deterministic values per (OS, browser build, device model), so the raw strings carry maximum entropy and stay stable across page loads on the same device. Non-string fields and missing fields both collapse to `''` via the `readUachField` helper so the four-column shape is preserved across all Chromium variants.

The signal complements the NaN-sign-bit `architecture` signal and the `platform` signal. The NaN byte tells the JIT-level CPU family; UA-CH `architecture` tells the OS-claimed CPU family. They should agree on real hardware and diverge when a UA-CH override extension is active. UA-CH `platformVersion` recovers the OS version number that Chromium's UA reduction deliberately freezes out of `navigator.platform`.

How it's collected

The collector uses the two-stage Source pattern introduced in 1.20.0. `dispatchUserAgentData()` (Phase A) reads `navigator.userAgentData.getHighEntropyValues` and immediately fires the async call if it is available, stashing the resulting Promise on the `DispatchHandle`. If the API is absent or not a function, the handle carries a resolved `{ kind: 'unsupported' }` payload synchronously. Any rejection from `getHighEntropyValues()` is captured inside the Promise chain as `{ kind: 'error' }`, so the handle never rejects.

`resolveUserAgentData()` (Phase B) awaits the handle's payload, reads the four hint strings via `readUachField`, joins them in the fixed order `architecture|bitness|model|platformVersion`, and hashes the concatenation with xxHash64. If the payload kind is `'unsupported'` or `'error'`, or the resolved object is not a plain object, the result is absent. Because dispatch fires the Promise in Phase A alongside every other collector, the async API roundtrip (typically sub-millisecond on modern Chromium) overlaps entirely with concurrent Phase B work.

The canonical one-shot path `collectUserAgentData()` calls `resolveUserAgentData(dispatchUserAgentData())` back-to-back, producing byte-identical results with no architectural difference; both the one-stage and two-stage paths exercise the same code.

typescript
const HIGH_ENTROPY_HINTS = [ 'architecture', 'bitness', 'model', 'platformVersion',
] as const; // Phase A: fires the async call immediately
const payload = getHints.call(uad, HIGH_ENTROPY_HINTS).then( (data) => ({ kind: 'data' as const, data }), (error: unknown) => ({ kind: 'error' as const, error })
); // Phase B: awaits, reads, hashes
const architecture = readUachField(data, 'architecture'); // 'arm' | 'x86' | ''
const bitness = readUachField(data, 'bitness'); // '64' | '32' | ''
const model = readUachField(data, 'model'); // 'Pixel 7' | ''
const platformVersion = readUachField(data, 'platformVersion'); // '14.6.1' | '' const hash = hash64([architecture, bitness, model, platformVersion].join('|'));

The four hints requested from getHighEntropyValues() and the hash construction. Field order is fixed and is part of the hash contract.

Confidence rules

ConfidenceTrigger
normalgetHighEntropyValues() resolved with an object, even if all four fields are empty strings
absentnavigator.userAgentData is undefined (Safari or Firefox); sentinel: 'unsupported'
absentgetHighEntropyValues is not a function (sentinel: 'unsupported')
absentThe Promise rejected (e.g. NotAllowedError under tight Permissions-Policy); sentinel: 'threw'
absentThe resolved payload is not a plain object (sentinel: 'unsupported')

Why engine-bound

The `navigator.userAgentData` API is a Chromium invention. Safari has not implemented it; Firefox has not implemented it. If this signal were placed in `the hardware signal set`, Chrome and Safari on the same physical machine would produce different hardware fingerprints even though the CPU, RAM, and GPU are shared, because one browser provides a value and the other does not. That would violate the hardware-binding invariant.

The rationale is identical to `js_heap_size_limit`: data that only one engine family can supply must be engine-bound. The signal contributes entropy to the full `fingerprint` on Chromium-family browsers and contributes `__absent__` on all others, with no impact on `hardwareFingerprint`.

Things worth knowing

  • platformVersion reflects the OS minor version (e.g. '14.6.1' for macOS Sonoma 14.6.1). When the OS updates, this value shifts and so does the hash. Treat the hash as engine-bound and OS-build-bound.
  • model is empty on all desktop browsers including Chrome on macOS and Windows. On Android, Chromium returns the device-marketed name (e.g. 'Pixel 7'), a strong device-class discriminator but not a unique device identifier.
  • The collector deliberately does not request wow64, fullVersionList, or formFactor hints that the UA-CH spec defines. Requesting additional hints in a future version would shift the hash, so treat any hint expansion as a one-time fingerprint migration similar to the canvas RGBA migration in 1.3.0.
  • FingerprintJS v4 and Thumbmark both collect UA-CH high-entropy values. This signal differs in that the four fields are hashed verbatim; Thumbmark lowercases model and FingerprintJS bucketizes Android device families. Verbatim hashing preserves maximum entropy because Chromium's output is already deterministic.
  • The async roundtrip to the browser process is typically sub-millisecond but can reach ~100 ms on some Chromium implementations that serialise the call across an IPC boundary. The two-stage pattern ensures this cost is masked by concurrent Phase B work.
  • Missing or non-string fields collapse to '' in readUachField rather than being omitted. This preserves the four-segment shape so the hash structure is consistent even when a hint returns null or undefined in an older Chromium build.

Last reviewed 2026-06-04