Skip to main content
A web page learns about the machine it runs on through hundreds of values: navigator.hardwareConcurrency, the WebGL renderer string, screen.availHeight, the fonts it can measure, the voices speechSynthesis lists, and the HTTP headers the browser sends. A detector reads many of them and checks that they agree with each other. Apostate changes each of these values in Chromium’s C++, at the place Chromium produces it. Every API, worker, iframe and header that reads the value then reads the new one, and Apostate adds nothing to the page that a script could find. The values come from one document, the profile, which the browser composes from a seed when it starts.

The profile

The profile is a JSON document of the values a machine shows: platform, User-Agent, cores, memory, GPU strings and limits, WebGPU adapter, screen, fonts, voices, audio, media devices, network, battery and a few more. Profile schema lists every section and field. A section the profile leaves out stays the host’s. The browser process composes the profile before it starts any other process. Every child process, whether renderer, GPU process or network service, receives the same profile, base64-encoded, in its --apostate-profile switch. No child draws values of its own, so a value computed in two processes agrees. The Device-Memory header, which the browser process sends, and navigator.deviceMemory, which a renderer answers, read the same number. Composition runs in ChromeMainDelegate::PreSandboxStartup, after the user data directory is known and before any child exists. On Linux that is before the zygote forks, so every renderer forked from it carries the profile. The browser writes nothing to disk apart from the identity file.

Composing a machine

The catalogue says what a machine can be. It lives in three places in the repository: scripts/generate-dispersion-tables.py compiles them into the browser during the build. The browser never reads them from disk. A machine is a series of weighted choices, made in this order:
  1. platform (the persona)
  2. OS release
  3. GPU family
  4. GPU model
  5. machine class, such as a desktop tower, a 15-inch laptop or a MacBook Pro with a Studio Display
  6. CPU cores
  7. memory
  8. screen
  9. taskbar, menu bar or panel
  10. font packs
  11. media devices
  12. audio
  13. network
  14. battery
  15. voices
  16. extensions
Later choices are keyed on earlier ones. Cores and memory depend on the GPU model, and the screen, media devices and battery depend on the machine class. An Apple M1 therefore gets 8 cores, an M4 Max 14 or 16, and a MacBook Air never gets a Studio Display. For each choice, the browser takes a SHA-256 hash of the seed, the persona and three fixed version epochs, then a second hash of that result with the choice’s name, and picks an option from the weights with it. The draw uses no floating point and no modulo, so the same inputs pick the same option on every host. Before each draw, the browser removes the options the host cannot serve, such as more cores or memory than it has. Seeds and identity covers what that means for stability. Language and timezone are not drawn. A drawn timezone could not match the IP address a site sees. The packages look up the proxy’s exit and pass --fingerprint-locale and --fingerprint-timezone. See Locale and timezone. --fingerprint-explain prints every choice a launch made, the layer that made it and the evidence behind the option, then exits. Personas shows the output.

GPU families

A GPU family is a set of WebGL and WebGPU values measured on real hardware: the WebGL limits, the extension list, shader precision, context attributes, and the WebGPU adapter’s features and limits. A persona takes one family whole. Mixing values from two families would describe a device that does not exist. Within a family the WebGL values are identical for every model, so a persona can name any model of the family by its renderer string. The WebGPU adapter’s architecture name follows the model’s chip generation, and so does the smallest subgroup size on Intel’s 12th-generation graphics. GPU models lists the models, and GPU covers choosing one. The persona picks the family, not the host’s GPU. A Windows persona takes a Direct3D 11 family on any host, including a Linux server with no GPU. The host’s CPU family narrows the choice, because a Windows persona reports it. An x86 host gets the NVIDIA or Intel family, and an ARM host gets the Qualcomm family.

Where the values come from

The project captured real machines with the collector page in capture/, in headed Chrome, and keeps the results in resources/fingerprints/raw/:
  • macOS on an Apple M4 Max
  • Windows with an NVIDIA RTX 3070 Ti, an NVIDIA RTX A4500 and Intel UHD Graphics 630
  • Windows 11 on ARM on a Snapdragon X2 Elite laptop with a Qualcomm Adreno X2-90
  • Linux with an NVIDIA RTX 3060, RTX 3090, RTX 4070 Ti SUPER, RTX 4080 SUPER and RTX PRO 4000
  • stock headless Chrome on Linux and macOS, for comparison
The GPU families, voices, audio buffer sizes, taskbar and menu bar sizes, and the macOS and Linux font lists come from these captures. The Windows core counts and screens are weighted from a large public collection of older Windows fingerprints. That collection is someone else’s data, so it stays outside the repository and no build step reads it. The Windows font list is the set of families common to two public Windows 11 font collections. The remaining options, such as which memory sizes Apple sells a chip with, are written from vendor specifications. Each option’s evidence field says which of these it is.

A value changed at its source

Every change is a patch to the Chromium code that produces a value. Take the core count. A page reads it from navigator.hardwareConcurrency, but Chromium also sizes its thread pools from the same number, in base::SysInfo::NumberOfProcessors(). Changing only the JavaScript getter would leave a 32-thread browser claiming 8 cores, and a page that times parallel work in workers would see the difference. Patch 0004 changes the function the thread pools and the getter both call:
The other values follow the same rule:
  • navigator.platform is set at the constant NavigatorBase returns, so workers report the same value as the window.
  • The User-Agent Client Hints come from the one UserAgentMetadata struct that feeds every Sec-CH-UA header and navigator.userAgentData.
  • The screen is set on the ScreenInfo the browser process sends to renderers, so screen.*, the CSS media queries and the pixel-ratio client hint agree, in top frames and in out-of-process iframes.
  • One predicate hides fonts from canvas and CSS text measurement, @font-face local() and queryLocalFonts(). document.fonts.check() returns true for any family name, installed or not, so it does not read the font list.
Apostate injects no script, redefines no getter and sets no DevTools override. patches/series lists the patches in the order they apply, and each patch file starts with a description of what it changes. Patches explains how to write one.

Which setting wins

Strongest first:
  1. --apostate-profile describing a device: your profile, nothing composed.
  2. --fingerprint=host: the host’s own values, nothing composed.
  3. Per-field switches, such as --fingerprint-hardware-concurrency: one value each, on top of the seed’s machine.
  4. --fingerprint=SEED.
  5. The seed in DIR/apostate/identity, when --user-data-dir=DIR is set.
  6. A fresh seed from OS entropy.
--fingerprint=host refuses to launch together with a persona, a GPU family or a per-field switch. A written profile replaces the seed’s machine, so the packages refuse --fingerprint in args next to a profile. Switches lists every switch.

Host mode

--fingerprint=host turns composition off. The browser builds no profile, and every value a persona would change is the host’s. Use it to tell whether a problem comes from the persona or from the machine and network under it. Personas has the details.