Skip to content
Fingerprinting

Cross-realm coherence: why a worker must agree with the page

A page is not one JavaScript realm but several — and the values they report about the same machine have to match.

Pim
Pim· Clearcote Research
7 min read

Key takeaways

  • A page is several JavaScript realms — window, dedicated Worker, SharedWorker, iframe — each with its own global and its own copy of every built-in; a main-thread override does not propagate to any of them.
  • WorkerNavigator is a distinct object in a distinct global, supplied by the engine — so patching window.navigator leaves the worker reporting the real value, and the disagreement is the signal.
  • A well-built cross-realm check knows when not to assert: language and languages are deliberately excluded because Chrome has a long-standing legitimate window-vs-worker divergence there.

A page is not one JavaScript realm

A realm, in the ECMAScript sense, is a global object plus its own complete set of intrinsics — its own Object, its own Function.prototype.toString, its own Navigator. Loading one page creates several of them. The window is one. Every <iframe> is another. A dedicated Worker, a SharedWorker and a ServiceWorker each run in their own WorkerGlobalScope. So does a fresh about:blank frame created with no src at all — it inherits the parent's origin, so the parent can reach straight into contentWindow and read a set of intrinsics that were born seconds ago, directly from the engine, with no page script in between.

These realms are not independent machines. They are views onto one browser process on one device. The CPU count does not change between the window and a worker. The GPU does not change. The operating system does not change. So every realm has to answer the same way about the same immutable facts — and a page can spawn a new realm at any moment and ask again.

That property is what makes cross-realm reading such a productive detection technique. It needs no reference corpus, no population statistics, no baseline of what a "normal" device looks like. It asks one internal question — do these two realms of the same page agree? — and a real browser can only ever answer yes.

WorkerNavigator is a different object in a different global

JavaScript patching is realm-scoped by construction. Object.defineProperty(navigator, 'hardwareConcurrency', …) mutates the Navigator instance belonging to the realm in which that line executed. A worker's global does not expose Navigator at all — it exposes WorkerNavigator, a different interface backed by a different object, constructed by the engine when the scope is created. Page scripts never run inside it. There is no propagation, and no mechanism by which there could be.

This is why a stealth patch that looks complete from the console is often trivially contradicted. The window says twelve cores; the worker, whose WorkerNavigator came from the same engine that knows the real number, says four. A detector does not need to know which one is true. Two answers to a question that has one answer is already the finding — and it localises the modification to exactly the realm the injected script reached.

The same reasoning applies to a fresh iframe, which is why the audit's iframe-coherence check is scored critical. It appends a hidden, src-less iframe to documentElement and diffs the child realm against the top one on platform, hardwareConcurrency, userAgent, typeof navigator.webdriver, the full screen geometry, devicePixelRatio, maxTouchPoints, plugins.length and the timezone offset — the last one read through the frame's own Date, so a main-realm hook cannot launder it. It also stringifies the main realm's natives using the iframe's Function.prototype.toString, which flanks a toString-hiding shim installed in the parent.

Notice what that check deliberately leaves out: innerWidth (a viewport-sized frame legitimately matches its parent) and pageXOffset (a scrolled parent and an unscrolled child differ on every real browser). Realm-paired reads are only evidence when the two realms are genuinely obliged to agree.

What a cross-realm diff actually compares

The live audit spawns a dedicated Worker and a SharedWorker from blob: URLs and races them in parallel, each with a 2,500 ms timeout. This is a deliberate departure from CreepJS, which serialises service → shared → dedicated with 4 s / 3 s / 3 s timeouts — up to ten seconds of wall clock before anything resolves. Since the whole point is to diff every scope against the window, every scope's answer is wanted regardless; racing them costs nothing and returns in a fraction of the time. A ServiceWorker realm is reached separately, from a real same-origin script rather than a blob: URL, because ServiceWorker registration rejects the blob: scheme outright.

Each scope runs one shared probe body and reports its own self.constructor.name (DedicatedWorkerGlobalScope, SharedWorkerGlobalScope) plus its navigator readings. The worker-realms-agree check then diffs four fields against the window — userAgent, platform, hardwareConcurrency, deviceMemory — flagging any worker value that is defined and stringifies differently. A worker field that is undefined is skipped rather than failed, because not every scope exposes every field.

The fifth comparison is the interesting one: a computed engine id, derived from the length of the RangeError message the engine throws for (-1).toFixed(-1) plus the length of the Array constructor's source with its own name removed. 80 is V8, 58 is SpiderMonkey, 77 is JavaScriptCore. No user-agent override, CDP patch or userAgentData shim touches either surface — the engine cannot be wrong about its own error wording. Running that oracle inside every worker asks whether all the realms are even the same engine, not merely whether they tell the same story. The mechanics are covered in identifying the engine behind the user-agent.

One row in that section asserts nothing at all. worker-scopes-reached is hardcoded pass: null, severity info, and exists only to name which realms actually answered. That distinction matters: "we compared two realms and they agreed" and "nothing answered" are different epistemic states, and a check that silently reports the second as the first is broken. If no scope answers, every worker comparison above it reports not-applicable rather than a pass.

The same drawing, rendered twice

Identity strings are the easy half. The harder half is rendered output, because OffscreenCanvas lets a worker build its own 2D or WebGL context and draw the same thing the page drew — from a realm where the page's canvas patches do not exist.

The worker-vs-main check measures five strings at 12px Arial"A", runs of ten W, m and i, and "Coherence 0123" — once on a main-thread 2D canvas and once on an OffscreenCanvas inside a dedicated Worker, comparing to a tolerance of 1e-9. Text shaping runs the same engine code in both scopes, so an unmodified browser returns bit-identical widths. A farble applied to the page's CanvasRenderingContext2D — the realm injected scripts land in — never reaches the worker, and the two sets diverge.

GPU identity is checked the same way twice over. webgl-worker-vs-main builds a WebGL context on an OffscreenCanvas(32,32) in a worker, reads UNMASKED_VENDOR_WEBGL and UNMASKED_RENDERER_WEBGL through WEBGL_debug_renderer_info, and requires exact equality of both strings against the main thread. The broader worker-gpu-agrees check runs across every worker tier that answered but asserts on the renderer only. Both are scored warn, not critical — unlike worker-realms-agree, which carries the navigator identity and is critical. The renderer string is the softer, more environment-dependent signal of the two.

No physical machine presents two GPUs to two scopes of one page; a hook on the page's WebGLRenderingContext.prototype.getParameter does exactly that. See WebGL fingerprinting for the underlying surface.

Knowing when not to assert

Here is the nuance that separates a usable cross-realm check from a noisy one. The worker probe collects language and languages along with everything else — and worker-realms-agree deliberately does not compare them.

The reason is empirical, not theoretical: Chrome has a long-standing, legitimate divergence between the window's navigator.language/languages and what a worker reports. A worker scope can resolve these from a different source than the document does, and stock, unmodified, freshly-downloaded Chrome will happily hand you two different answers. An implementer who reasoned purely from first principles — "the language is a device fact, therefore all realms must agree" — would ship a check that fires on ordinary users running an ordinary browser.

That is the whole discipline in one field. A cross-realm assertion is only worth making where the platform actually guarantees agreement. Everywhere else, a disagreement is a property of the browser, not evidence about the session, and asserting on it converts a detector into a random-number generator with a plausible-sounding rationale. The same restraint shows up elsewhere in the audit: a software WebGL renderer is info severity because VDI, RDP and driverless Linux produce it legitimately; there is no blob:-worker-source check at all, because the audit spawns its own probes from blob: URLs and such a check would convict its own implementation on every run.

False positives are not a rounding error in detection — they are the constraint that determines whether a signal can be deployed at all. A check that flags stock browsers cannot be scored, no matter how elegant its mechanism.

The through-line

Cross-realm reading is the sharpest expression of a general principle: detection is mostly not about whether a value is unusual, but about whether the values agree. A worker cannot be argued with. It was not there when the patch ran, and it answers from the engine. The only way to make every realm tell the same story is for the story to be true at a level below JavaScript — which is why identity control tends to migrate into the engine itself rather than into injected script. Clearcote is one open-source implementation of that idea; the detection breakdown covers the mechanics.

The design lesson runs both ways. If you are building automation, every new realm a page can open is another place a realm-scoped patch has to reach, and the set is unbounded — nested workers, dynamically created frames, a SharedWorker shared across tabs. If you are building a detector, the realms give you assertions that need no baseline at all, provided you are honest about which ones the platform actually guarantees. Read coherence over camouflage for the principle, why a worker must report the same values as the page for the short version, or run the checks yourself at the audit.

#coherence#fingerprinting#bot-detection#reference

Clearcote puts this into practice

Open-source, engine-level, coherent down to the TLS handshake — a drop-in for Playwright & Puppeteer.