Skip to content

Iframe and worker context fingerprinting: where stealth patches forget to reach

· 20 min read
Copyright: MIT
Wordmark reading context coverage with an orange accent bar, beside a diagram of main, iframe, and worker execution contexts

A stealth-patched browser spends most of its effort on one execution context: the top document’s main world. That is where navigator.webdriver gets deleted, where navigator.languages gets a proxy, where the WebGL renderer string gets rewritten. The patches are careful and they mostly work. Then a detection script creates an empty iframe, reads navigator.hardwareConcurrency from inside it, and gets the real number back, because the patch that was supposed to spoof it never ran in that frame. Or the script spins up a web worker, asks navigator.platform over a postMessage channel, and learns that the machine claiming to be Windows is actually Linux.

This is the gap. A page is not a single JavaScript environment. It is a tree of them, plus a set of off-thread environments hanging off the side, and each one has its own copy of the objects a fingerprint reads. Injected stealth code lives in whichever contexts the injector bothered to reach. Detection code lives in whichever contexts it chooses to look. When those two sets do not match, the mismatch is the signal, and it does not require any clever heuristic to find. It requires asking the same question twice and comparing the answers.

This post walks the context tree from the perspective of someone trying to cover all of it. It starts with why the main world is the easy case and everything else is not. It looks at the CDP injection path and where it structurally fails: out-of-process iframes, about:blank and srcdoc frames, and workers that cannot host an isolated world at all. It covers the cross-context consistency check, the cheapest detection in the whole category. It examines one specific stealth bug, the puppeteer-extra iframe proxy leak, as a worked example of getting the mechanism almost right. And it ends on why this surface keeps reopening even after every individual hole is patched.

The main world is the easy context

When a stealth tool patches a Chromium-based automation browser, it injects JavaScript that redefines properties before page scripts run. The standard delivery mechanism is the Chrome DevTools Protocol command Page.addScriptToEvaluateOnNewDocument, which registers a script to run at document_start on every new document. Puppeteer exposes it as evaluateOnNewDocument, Playwright as addInitScript. The script runs early enough to redefine navigator getters before any page code observes the originals. For the top frame’s main world, this is solved. Delete webdriver, wrap languages and plugins in proxies, override WebGLRenderingContext.prototype.getParameter to return a plausible GPU string, and the main-world fingerprint reads clean.

The trouble is that “the page” is not one environment. Every frame gets its own window, its own navigator, its own Navigator.prototype. A worker gets a WorkerGlobalScope with a WorkerNavigator that is a separate interface from the window’s Navigator. Patching the top frame’s Navigator.prototype.hardwareConcurrency does nothing for a worker, because the worker never touches that prototype. It reads WorkerNavigator.prototype.hardwareConcurrency, which lives in a different global, possibly on a different thread, and which the injection never visited.

So the patch surface is not one object graph. It is N of them, where N is the number of frames plus the number of workers, and N is controlled by the page, not by the automation tool. A detector that wants a clean read of the unpatched browser simply asks for a context the patch did not reach.

one tab, many JavaScript environments top frame main world <- patch lands here isolated world (utility) same-origin iframe own window+navigator cross-origin iframe OOPIF: separate target web worker separate thread WorkerNavigator no isolated world OffscreenCanvas -> WebGL A patch that covers only the top main world leaves every orange box reading the real, unpatched browser. *The contexts a fingerprint can be read from. The orange boxes are where naive injection does not reach.*

The philosophical split here is the subject of a separate post on source-patched Chromium versus runtime injection. A browser patched at the C++ level changes RuntimeService::ClampedHardwareConcurrency once and every context, window or worker, reads the patched value because they all call the same implementation. A browser patched at the JavaScript level has to chase every context individually. The context-coverage problem is the runtime-injection tax, and it is paid per environment.

Why CDP injection does not cover the tree

The injection mechanism inherits Chromium’s frame and process model, and that model was built for security, not for making one script appear everywhere.

Modern Chromium runs Site Isolation by default. A cross-origin iframe is rendered in a separate OS process and becomes an out-of-process iframe, an OOPIF. From the DevTools Protocol’s view, an OOPIF is not part of the parent’s frame tree in the way a same-origin iframe is. It is a separate target with its own session. A command sent on the page’s session does not automatically reach it. To inject into an OOPIF you have to attach to its target, get a sessionId, and re-issue Page.addScriptToEvaluateOnNewDocument on that session.

Puppeteer historically did not do this. In issue 12706, filed in July 2024 against Puppeteer 22.12.1, evaluateOnNewDocument was confirmed to run in the parent frame but not in cross-origin OOPIFs: the parent read the injected value, the iframe read undefined. The maintainers marked it confirmed at P3. The workaround the reporter found involved listening for sessionattached events and manually injecting into each child session, which they described as sketchy. The point is not that Puppeteer had a bug. The point is that covering OOPIFs is extra work that the high-level API did not do for you, so anyone who did not know to do it shipped a browser whose cross-origin frames read clean.

about:blank and srcdoc frames are the same problem from a different angle. These frames often have no navigation in the usual sense, so the document-creation hook that fires addScriptToEvaluateOnNewDocument may not fire as expected. There is a documented Chromium edge case: when an iframe cancels its initial navigation through document.open(), the inspector did not create isolated worlds and did not evaluate injected scripts on the resulting document. The fix for that specific case (Gerrit change, Dmitry Gozman, commit landed late October 2021) was narrow, and it was reverted days later because forcing context creation broke assumptions elsewhere, including GuestView. The history matters because it shows the shape of the problem. The initial empty document of a frame is a place where the normal “new document, run the init script” contract is fuzzy, and detectors know it.

A real page creates about:blank and srcdoc iframes constantly. Ad frames, sandboxed embeds, and dozens of libraries write content into a freshly created frame before navigating it anywhere. So a detector that reads the fingerprint from a same-origin about:blank child is not doing anything exotic. It is reading from a context that real sites use all the time and that injected patches frequently skip.

The deeper reason CDP injection competes with itself here is covered in the post on the addScriptToEvaluateOnNewDocument trap: the same command that delivers your patches is observable, and the more contexts you fan it out across, the more surface you create. Covering the tree and staying quiet pull in opposite directions.

Workers cannot host an isolated world

The worker case is structurally worse than the iframe case, and it is worth being precise about why.

The cleanest place to run patch code is an isolated world, a separate JavaScript context that shares the DOM but has its own global scope, created with Page.createIsolatedWorld. Page scripts cannot see variables in it, so the patch is hard to detect through MutationObserver or global inspection. Stealth tools that care about not being seen prefer to run there. But a worker has no DOM and no concept of an isolated world. The rebrowser-patches documentation states it plainly: web workers do not allow creating new worlds, so you cannot execute your code inside a worker by that route. The “always isolated” strategy that protects you in frames leaves workers completely uncovered, because the strategy does not apply there at all.

That is why Page.addScriptToEvaluateOnNewDocument historically did not patch workers, and why a worker would read the genuine browser. A WebWorker exposes WorkerNavigator, which carries userAgent, hardwareConcurrency, platform, language, languages, deviceMemory, userAgentData, and more, almost everything a navigator-based fingerprint wants, just on a different interface in a different global. If the spoof lives only on the main thread’s Navigator.prototype, the worker’s copy is untouched.

The current generation of patches works around this with a different injection primitive. Rebrowser-patches, in its default addBinding mode, creates a binding in the main world and uses the context that calls it, an approach the project says keeps main-world access and works with both web workers and iframes. The README notes the worker support specifically, that it was added because Page.addScriptToEvaluateOnNewDocument did not work in workers. As of the patch versions tracked in 2026 (Puppeteer 24.8.1 from May 2025, Playwright 1.52.0 from April 2025), workers are covered by the binding path rather than the init-script path. That is the state of the art, and it is a workaround, not a guarantee. A page can create a worker the moment it loads, and the binding has to be in place before the worker reads its navigator, which is a race the patch has to win every time.

There is a second-order tell hiding in the worker creation itself. Spoofing a worker’s navigator means intercepting Worker construction or importScripts, or rewriting the worker’s source before it loads, so that the spoof code runs first inside the worker scope. Every one of those interceptions touches a global the page can also touch. If a detector holds a reference to the original Worker constructor captured at the very top of the document, before any patch has had a chance to run, and later compares it against window.Worker, a replaced constructor stands out. The patch has to be early enough to beat the detector’s capture and quiet enough that the replacement is not obvious, in a context where it cannot use an isolated world to hide. Those constraints fight each other.

Service workers are a different animal

Web workers and service workers get lumped together, but for this discussion they behave differently in two ways that matter. A service worker outlives the page that registered it. It is installed against an origin and scope, it persists across navigations, and it can be started by the browser without a controlling page in view. So a fingerprint read from a service worker is read from a context whose lifecycle the automation tool may not be driving at all. If the tool’s patching is tied to page lifecycle events, a service worker that the browser spins up on its own schedule is a context the patch was never invited to.

The other difference is what a service worker can see. It sits in the network path for the pages it controls and can intercept every fetch those pages make. That gives a detector inside a service worker a vantage point an ordinary page script does not have. It can observe the actual outgoing request headers, including the real User-Agent and the Sec-CH-UA client hints the browser attaches, and compare them against what the page’s JavaScript claims navigator.userAgent is. Header spoofing applied at the CDP layer with Network.setUserAgentOverride and JavaScript spoofing applied to navigator are two separate writes. If they disagree, or if the structured Sec-CH-UA hints do not decompose to the same browser and platform that the JS navigator reports, a service worker is positioned to catch the split from inside the browser, without the detector’s server ever being involved.

The WorkerNavigator exposed to a service worker carries the same navigator surface as a web worker, so the consistency check applies there too, with the added wrinkle that the service worker may be answering from a state established on an earlier navigation. A spoof that changes between page loads, a rotating user agent for instance, can disagree with a service worker that cached the value from registration. Self-inconsistency across time is as damning as self-inconsistency across threads.

The consistency check is the whole game

Here is the detection that makes all of the above pay off, and it is almost embarrassingly cheap. You do not need to know which specific contexts a bot patches. You read one property from two contexts and check that they agree.

Nikolai Tschacher documented exactly this against a commercial scraping service in March 2021. The service presented a Windows user agent, Mozilla/5.0 (Windows NT 10.0; Win64; x64), so navigator.platform on the main thread read consistently as a Windows value. Inside a web worker, the same navigator.platform came back as Linux x86_64. The user-agent spoof had been applied to the window’s navigator and to the HTTP headers, but not to the WorkerNavigator running on the worker thread, which still reported the real Linux host. The same write-up noted navigator.hardwareConcurrency differing too: two cores in the worker, four in the window. A real browser cannot produce that disagreement. The platform a worker runs on is the platform the page runs on. The instant the two answers differ, the only explanation is tampering.

ask the same question twice main thread navigator.platform "Win32" (patched) worker thread navigator.platform "Linux x86_64" (real) platform(main) != platform(worker) => spoof No threshold, no model. A genuine browser cannot disagree with itself about the OS it is running on. *The cross-context consistency check. The detector does not need to know how the spoof was built, only that two readings of the same fact contradict each other.*

What makes this class of check strong is that it is self-calibrating. Most fingerprint signals need a reference: is this WebGL renderer string plausible for this user agent, is this screen size real, does this timezone match this IP. The consistency check needs no external reference. It compares the browser against itself. A legitimate Chrome on Windows returns Win32 from both the window and a worker, every time, with zero variance. The check has effectively no false-positive cost, which is why it is one of the first things a serious detector reaches for. The same logic extends across the whole navigator surface: user agent, languages, deviceMemory, hardwareConcurrency, and the structured userAgentData. Anywhere a spoof patched the window but not the worker, or the worker but not the window, the two readings split.

Frames give the same check a slightly different shape. A detector does not even need a worker to find a coverage hole. It can create a same-origin iframe, reach into iframe.contentWindow.navigator, and read the property there. If the patch redefined the property on the top frame’s Navigator.prototype but the child frame got a fresh prototype the patch never visited, the child reads the real value while the parent reads the spoof. The split is the same equality failure as the worker case, sourced from a frame instead of a thread. And because the child is same-origin, the detector reaches its navigator directly with no CDP, no cross-origin barrier, nothing but a few lines of ordinary page JavaScript. Some detectors go further and re-derive the whole navigator fingerprint inside a nested frame as a matter of routine, on the assumption that a patch is more likely to have covered the obvious top-level objects than the ones that only exist after the page builds them. This connects to the larger point in how anti-bot systems fingerprint the JavaScript runtime: the runtime is not one object graph to be audited once, it is a family of them, and a spoof is only as good as its least-covered member.

Workers also reopen fingerprinting surfaces that people assume are DOM-bound. A worker has no <canvas> element, but it has OffscreenCanvas, and OffscreenCanvas can get a WebGL context. So the GPU renderer string, the canvas-rendering hash, the whole graphics fingerprint can be collected off-thread, from a context where a main-world getParameter override never ran. If the WebGL spoof lives on the window’s WebGLRenderingContext.prototype and the detector reads the renderer through an OffscreenCanvas inside a worker, it gets the real GPU. The FP-Radar measurement study (Bahrami, Iqbal, and Shafiq, PoPETs, December 2021) tracked exactly this migration of fingerprinting into workers and off-thread APIs as sites moved toward more opaque collection. The relevance here is direct: any graphics spoof that only covers the main thread has an OffscreenCanvas-shaped hole in it.

A worked example: the iframe proxy that leaked its handler

It is one thing to skip a context entirely. It is another to patch it and get the patch subtly wrong, which leaves a tell that is arguably worse, because now there is a positive artifact to match on rather than just an inconsistency.

The puppeteer-extra-plugin-stealth project shipped an iframe.contentWindow evasion for years. The problem it addressed is real: in some automation configurations, reading iframe.contentWindow on a freshly created frame behaved differently from a normal browser, which was itself a tell. The evasion hooked iframe creation and replaced the frame’s contentWindow with a JavaScript Proxy, so accesses could be routed to a believable mock window.

The bug was in the proxy’s get trap. To make window.self self-referential, the way it is in a real window, the trap needed to return the proxy target for the self key. Instead, for that path it returned this. In a proxy handler’s get trap, this is the handler object, not the target window. So reading iframe.contentWindow.self did not loop back to a window. It handed out the handler object, and the handler object had the trap functions hanging off it as own properties. DataDome’s threat research team documented the detection in July 2024: create an iframe with a srcdoc attribute, then evaluate iframe.contentWindow.self.get?.toString(). On a real browser, self is the window and there is no get method to stringify, so the expression is harmless. On a browser running this evasion, self resolved to the handler, .get was the trap function, and .toString() returned its source. The presence of a stringifiable get on contentWindow.self was a direct, unambiguous signature of the stealth plugin.

This is the failure mode worth sitting with. The evasion ran in the right context, the iframe. It got the hard part, intercepting frame creation and standing up a proxy, basically right. It lost on a single line where this did not mean what the author assumed inside a trap. And the same evasion had a second, louder failure: on pages that used srcdoc iframes themselves, particularly some WordPress plugin embeds, the proxy interfered badly enough to null out document.body and blank the page, reported as puppeteer-extra issue 909 in August 2024. A patch meant to hide the bot was breaking real sites and announcing itself at the same time.

The general lesson is that a context you patch incorrectly is detectable in a way a context you simply skip is not. Skipping a worker gives a detector an inconsistency to reason about, which still requires reading two contexts and comparing. A botched proxy gives a detector a constant to string-match, which requires reading one context and checking for a known artifact. The botched patch is cheaper to detect. Coverage that is wrong can be worse than coverage that is absent, a tension that runs through the broader account of why stealth plugins lose ground over time.

Where the field actually is in 2026

The naive version of this gap, patch the top main world and nothing else, is mostly closed in tools people actually use. The current patch generation knows about OOPIFs and re-injects per session. It knows about about:blank and srcdoc frames. The binding-based injection covers workers, where the older isolated-world strategy could not reach at all. If you run a recent, maintained patch set, a single-property consistency check across window and worker will not trivially light you up the way it did in 2021.

What has not changed is the structure that produces the gap. The page controls how many contexts exist and when they come into being, and a runtime patch has to reach every one of them, correctly, before any page script reads from it. That is a race and a coverage obligation that grows with every new context type the platform ships. OffscreenCanvas in workers turned the graphics fingerprint into an off-thread signal. WorkerNavigator already mirrors most of the navigator surface, and userAgentData extended it. Each addition is one more place the same fingerprint can be read, and one more place a patch has to be, in time, with the right value, or the consistency check finds the seam. The source-patched browsers covered in the patched-Firefox writeup sidestep the whole race by changing the value once at the implementation layer, which is why that approach keeps gaining on the injection approach for exactly these signals, even though Camoufox itself has shipped configuration overrides that silently failed to apply to platform, hardwareConcurrency, and oscpu in at least one 2025 beta. Covering every context is hard no matter which layer you do it at.

The thing to hold onto is how lopsided the economics are. A detector adds a worker read and a frame read once, wires up an equality check, and that check then costs nothing per request and never produces a false positive on a real browser. The automation side has to win the coverage race in every context, on every page, for every property, on every navigation, indefinitely. The detector is asking the browser to be consistent with itself, and a real browser is consistent with itself for free. That asymmetry is why the cheapest question in fingerprinting, asked from a context the page chose, keeps being one of the most expensive to answer.


Sources & further reading

Further reading