Skip to content

How anti-detect browsers spoof fingerprints at the browser-engine level

· 23 min read
Copyright: MIT
The phrase engine-level spoof in monospace with an orange underline on a black background

A detection script asks the browser one question: render this string into a canvas, hand me back the pixels. The bytes that come back encode the GPU, the graphics driver, the font rasterizer, the sub-pixel anti-aliasing path, and a dozen smaller decisions the machine made on the way to the framebuffer. Two machines with the same hardware and the same browser version produce the same bytes. Change the machine and the bytes change. That is the whole game, repeated across a few hundred signals, and a fingerprint is the hash of all of them.

The interesting question is not how the script reads the canvas. It is where you intercept the answer. You can wrap HTMLCanvasElement.prototype.toDataURL in JavaScript and perturb the string before it returns. Or you can recompile the browser so the C++ that fills the pixel buffer produces different pixels in the first place, and never expose a JavaScript wrapper at all. Both change the fingerprint. Only one of them is invisible to a script that knows what a real toDataURL looks like from the inside. This post is about the second approach: what anti-detect browsers actually patch in the engine, how the spoofed value comes back through native code paths, and why that beats the wrapper.

The sections below walk the surfaces one at a time. First the structural argument for why the engine is the right layer, told through the way Function.prototype.toString and property descriptors betray a JavaScript shim. Then navigator and the worker-context problem, canvas and the Skia anti-aliasing path, WebGL and audio, fonts and WebRTC. After that, the harder half: the consistency checks that catch naive randomization, and the places where even a recompiled engine still leaks. The recurring example is Camoufox, a patched Firefox whose source is public enough to read the patches directly, with Brave’s farbling and Mozilla’s own resistFingerprinting as the contrast cases.

Why the layer you patch at decides whether you are caught

Start with the failure mode, because it explains everything that follows. A stealth plugin in JavaScript spoofs a property by redefining it. To change navigator.hardwareConcurrency from 16 to 8, you call Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 }). The page now reads 8. Job done, until a detection script looks at the property instead of reading it.

A real navigator.hardwareConcurrency is an accessor defined on Navigator.prototype, not on the instance. Its getter, when you stringify it, says function get hardwareConcurrency() { [native code] }. The redefined version lives on the instance, and its getter stringifies to your arrow function source, or to function () { [native code] } only if you went to the trouble of faking toString. Faking toString means replacing Function.prototype.toString too, which has its own toString, which is the thread a detector pulls. As one security write-up on the Undetectable anti-detect browser put it, the injected script overrode the toString of eval but not the toString of the toString function itself, and that one missed layer was enough to surface the obfuscated code behind the mask.

There is a deeper version of the same problem. Detection scripts do not have to trust the toString they are handed. They can reach into a fresh <iframe>, grab that document’s pristine Function.prototype.toString, and apply it to the suspect getter. A JavaScript patch that ran in the top window did not necessarily run in the iframe’s freshly created realm, and even if it did, the cross-realm call exposes whether the function is genuinely native. The same trick works through a Worker. The WorkerNavigator object inside a Web Worker is a separate interface initialized per worker, and a shim that patched window.navigator has to also reach into every worker and every nested worker to stay consistent. Miss one context and the values disagree. Disagreement is the signal.

navigator.hardwareConcurrency → reads 8 JS shim (defineProperty) engine patch (C++) getter on the instance getter on the prototype toString → arrow source descriptor anomaly toString → [native code] indistinguishable worker / iframe realm may be unpatched → mismatch same value everywhere one source of truth *The same spoof at two layers. The JavaScript shim leaves a getter on the wrong object, a stringification that can betray it, and a per-realm consistency problem. The engine patch returns the value from native code that every realm shares.*

The engine-level answer removes the wrapper entirely. If you modify the C++ getter for hardwareConcurrency so it reads from a config and returns 8, the property still lives on the prototype, still stringifies to [native code], and returns 8 in the top window, in every iframe, and in every worker, because all of them call the same compiled code. There is no shim to find. As the Camoufox documentation frames it, the data is intercepted at the C++ implementation level, before it ever reaches the page, so JavaScript inspection has nothing to inspect. Castle’s own teardown of stealth tooling makes the inverse case: the reliable detections against JavaScript-patched browsers are descriptor anomalies, obfuscated getter sources, and stack traces that name the injection script’s own functions when an iframe is inserted. None of those exist when the change is compiled in.

This is the entire reason the serious anti-detect browsers ship a custom build instead of a plugin. The trade is real: you give up the convenience of patching a stock binary at runtime, and you take on the cost of maintaining patches against a moving browser source tree. What you buy is a fingerprint surface with no JavaScript seam. Crawlex has written separately about why stealth plugins lose the moment a detector stops reading properties and starts auditing them, and about the patch-by-patch anatomy of puppeteer-extra-plugin-stealth; the short version is that property patching and property auditing are not symmetric, and the auditor wins.

Navigator is the easiest surface to spoof and the easiest to get subtly wrong. The properties are simple strings and integers. userAgent, platform, oscpu, hardwareConcurrency, deviceMemory, language, languages. A page reads them, hashes them in with everything else, and moves on. The trap is consistency across the places navigator appears.

Camoufox’s navigator patch is a useful concrete example because the patch is public. It adds two new Gecko source files, dom/base/NavigatorManager.h and NavigatorManager.cpp, and modifies the existing dom/base/Navigator.cpp along with nsGlobalWindowInner.cpp, the Window.webidl interface definition, and critically dom/workers/WorkerNavigator.cpp. That last file is the tell that the author understood the worker problem. The spoofed userAgent, platform, oscpu, and hardwareConcurrency are read from a per-context store keyed by context id, with a global fallback through a MaskConfig lookup, so the same value is served whether the read happens on window.navigator or on self.navigator inside a worker. The exact storage-key format is an implementation detail of that codebase and not a stable interface, but the structural point holds: the value has one source, and every navigator object in every realm draws from it.

Compare that to what a JavaScript stealth plugin can reach. It runs an init script in the page. Workers spawned later need their own injected script, which the plugin has to arrange through the automation protocol, and the timing is awkward because the worker can read navigator before your patch lands. Nested workers compound it. This is one of the cleaner cross-context tells, and it is structural, not a bug you can patch around at the JavaScript layer. Move the spoof into WorkerNavigator.cpp and the timing race disappears because the worker’s navigator is born spoofed.

The other navigator subtlety is internal coherence. A spoofed userAgent claiming Windows has to come with a platform of Win32, an oscpu of Windows NT 10.0; Win64; x64, the matching set of system fonts, a GPU vendor string that a Windows machine would plausibly report, and an Accept-Language header on the network side that agrees with navigator.languages. Get one of those wrong and you have not hidden a fingerprint, you have minted a new and obviously synthetic one. Camoufox leans on BrowserForge-generated fingerprints for exactly this reason: the values are sampled from a distribution of real-world device profiles rather than set independently, so a Windows user agent never arrives with an Apple GPU. The browser is consistent because the profile it was handed was consistent before it ever booted. That coherence requirement is the thing JavaScript shims most often fail, and it is also the thing that makes engine-level spoofing necessary but not sufficient. A native patch that returns incoherent values is just as dead as a shim.

Canvas, and the pixels that come out of Skia

Canvas is the marquee fingerprint and the hardest to spoof well. The mechanism is plain enough. A script draws text and shapes into an offscreen canvas, then calls toDataURL(), getImageData(), or toBlob() to read the rendered pixels back as bytes, and hashes them. The variation comes from below the API: font hinting, anti-aliasing, sub-pixel rounding, and GPU rasterization differ across machines, so the same drawing commands land slightly different pixels. Those differences are stable per machine and distinctive across machines, which is exactly what a fingerprint wants.

There are two ways to spoof it, and the difference between them is the whole subject of the next section, so it is worth setting up precisely. You can perturb the pixels (add small, deterministic noise so the hash changes) or you can change the actual rendering path so the machine produces a different but internally honest image. Camoufox does the latter, and its canvas defense is the part of the project that stayed closed-source longest. The canvas anti-fingerprinting was added in v133.0-beta.19, and the mechanism is a patch to Skia’s anti-aliasing logic. Skia is the 2D graphics library Firefox and Chromium both use to rasterize canvas content. By altering how Skia anti-aliases edges, the patch changes the sub-pixel decisions that produce the fingerprint, so the canvas hash shifts to a value consistent with a different machine rather than getting random noise sprinkled on top. The source for that patch was held back while the rest of the project was public, and the broader source became readable around the v146 beta line in early 2026. Treat the precise internals as not fully public; what is documented is that the intervention point is Skia’s anti-aliasing, not a JavaScript wrapper on toDataURL.

draw commands → pixels → hash fillText 2D context Skia raster anti-aliasing pixel buffer toDataURL → hash JS-visible read engine patch intercepts here JS shim wraps here intercept upstream and the read API stays native and unwrapped wrap the read and toDataURL.toString stops saying [native code] *Intercept at Skia and the read APIs stay pristine. Wrap toDataURL and getImageData in JavaScript and you change the pixels, but the wrapper is now an object a detector can stringify and catch.*

Why does the interception point matter so much for canvas specifically? Because the read APIs are the most heavily audited functions on the platform. A detection script that cares about canvas will absolutely check whether toDataURL, getImageData, and toBlob are native. It will stringify them. It will compare them against the same functions pulled from a clean iframe. A JavaScript canvas spoof has to survive all of that while also producing perturbed pixels, and the perturbation logic itself is JavaScript that has to live somewhere a script could find. Move the change down into Skia and the read APIs are untouched, native, and boring. The pixels are different; the path to them is real.

WebGL and audio: the same idea, two more buffers

WebGL gives a fingerprinter two kinds of signal and both need handling. The cheap one is metadata: gl.getParameter() returns the unmasked vendor and renderer strings (ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Direct3D11 vs_5_0 ps_5_0) and friends), plus maximum texture sizes, shader precision formats, and the list from getSupportedExtensions(). The expensive one is the rendered image: draw a scene, read it back with gl.readPixels(), and hash it, because the GPU and driver leave the same kind of sub-pixel signature a 2D canvas does.

Spoofing the metadata at the engine level means the getParameter getter returns config-supplied strings, and getSupportedExtensions returns a list that matches the claimed GPU. Camoufox’s WebGL patch covers parameters, supported extensions, context attributes, and shader precision formats, which is the full set a metadata fingerprinter reads. The rendered-image side is harder and shades into the same Skia-style problem: you either perturb the read-back pixels or you accept the real ones and hope the GPU you are claiming is close enough to the GPU you have. This is one of the genuinely unsolved corners, and detection scripts know it, which is why the rendered WebGL hash is a favored cross-check against the claimed renderer string. Claim an RTX 3060 and render pixels that look like an integrated Intel GPU and the two disagree.

Audio is the cleanest of the three to reason about. The fingerprint comes from running a known signal through the Web Audio graph and reading the floating-point output. A script builds an OfflineAudioContext, feeds an OscillatorNode through a DynamicsCompressorNode, renders the buffer in memory, and reduces the samples to a number. Tiny differences in how the platform implements the compressor and the floating-point math produce a stable, machine-specific value. The other audio tells are scalar properties: sampleRate, outputLatency, maxChannelCount. Camoufox spoofs those scalars directly and also covers speech-synthesis voices and playback rates, because the installed voice list is itself an OS fingerprint. The processed-signal value is the audio analog of the rendered-canvas problem, and the same two options apply: deterministically perturb the output samples, or change the native math.

Brave’s approach to audio is worth holding up next to the patched-browser approach because it is the canonical example of the perturb path done carefully. In its default protection mode, Brave farbles the audio output by scaling the samples by a tiny factor (on the order of a fraction of a percent) derived from the per-session, per-site seed, so the signal is imperceptibly altered but the fingerprint hash changes; in maximum mode it replaces the output with low-amplitude white noise under the same seed. The seed is the important part, and it is the bridge to the next section.

The consistency trap, and why deterministic beats random

Here is the failure that separates toys from tools. The naive way to defeat canvas fingerprinting is to add random noise to the pixels on every read. It works once. It fails the second time a detection script reads the same canvas, because the script renders the identical drawing twice and compares the two hashes. A real browser returns the same hash both times; a canvas read is deterministic on a given machine. A browser that returns two different hashes for the same input has announced that it is randomizing, which is itself a fingerprint, and a loud one. Random noise does not hide you. It paints a target.

So the defense cannot be random. It has to be deterministic per identity and stable across reads, while still differing from the real machine and from other identities. This is the design Brave settled on and named farbling. The farbled values are generated from a seed that is fixed per session, per eTLD+1 site, and per storage area, so a given site reading the same canvas twice in one session gets byte-identical results, a different site gets a different result, and the next session reshuffles. Third-party frames inherit the top-level eTLD+1 seed, which keeps a page internally consistent across its own subframes. The same seed drives canvas, the WebGL extension list, and the audio scaling, so the whole fingerprint moves together as one coherent fake rather than a set of independently jittering values.

same canvas, read twice by the detector random per-read noise read 1 → a3f0 read 2 → 7c12 hashes differ → randomizer detected deterministic per-site seed read 1 → b918 read 2 → b918 hashes match → looks like a real machine seed = f(session, eTLD+1, storage area) same site, same session → same value every read different site or next session → different value one seed drives canvas, WebGL, and audio together *Random noise changes on every read, which a two-read consistency check catches instantly. A seed fixed per site and session returns the same value every time a given site looks, which is what a real machine does.*

The patched-browser camp and the farbling camp are solving the same constraint with different priorities. Brave wants every site to see a different you, so it reshuffles the seed per site and per session; its goal is to break cross-site linkage for a real human. An anti-detect browser wants one chosen identity to be perfectly stable for as long as it needs to be, so that a profile logging into the same account day after day presents the identical machine each time. Same seed mechanism, opposite settings: Brave maximizes diversity across sites, the anti-detect profile maximizes stability within an identity. Both reject pure randomness for the same reason, which is that inconsistency is the thing the detector is actually looking for.

This is also where Mozilla’s own approach diverges, and the contrast is instructive. Firefox’s privacy.resistFingerprinting, the output of the Tor Uplift project that shipped its MVP in Firefox 57 in late 2017, does not randomize and does not produce a plausible fake machine. It normalizes. It pushes every RFP user toward the same values: timezone reported as UTC, hardwareConcurrency clamped to 2, window dimensions rounded, the font set restricted, canvas image extraction blocked outright or gated behind a prompt. The bet is that if everyone looks identical, no one is identifiable. That is the correct privacy posture for a human who wants to disappear into a crowd. It is the wrong posture for an anti-detect browser, because the RFP value set is itself a recognizable signature; a hardwareConcurrency of exactly 2 with a UTC timezone and Tor-shaped window rounding is a fingerprint that reads as “RFP user,” and “RFP user” correlates with “wants to hide,” which is precisely the population a bot-detection vendor scores upward. Hiding the fingerprint and faking the fingerprint are different goals, and an anti-detect browser wants the second one.

Fonts, WebRTC, and the surfaces that leak around the engine

Fonts fingerprint two ways and both need a different fix. The first is enumeration: a script measures the rendered width of a string in a list of named font families, and the families that render at a non-fallback width are the ones installed, so the set of installed fonts becomes an OS-and-configuration signature. The second is metrics: even among shared fonts, sub-pixel glyph measurements vary. Camoufox handles both. It bundles the actual Windows, macOS, and Linux system font sets and serves the list appropriate to the claimed user agent through its font-list spoofing patch, and it defeats the metrics attack by applying a small randomized offset to letter spacing, so the measured widths carry a deterministic-per-profile jitter rather than the host machine’s true glyph metrics. Mozilla’s RFP solves the enumeration problem the normalizing way, by allowing only a fixed allow-list of fonts regardless of what is installed; the anti-detect approach serves a plausible per-identity set instead, which fits the fake-a-real-machine goal rather than the disappear-into-uniformity goal.

WebRTC is the surface most likely to undo all of the above, because it leaks at the protocol layer, below JavaScript and below most of the engine spoofing. A WebRTC connection gathers ICE candidates, and those candidates can include the host’s real local and public IP addresses, discovered through STUN independently of whatever the page’s JavaScript believes about the network. A browser can present a flawless spoofed fingerprint and still hand a detector the host’s true IP through a candidate. The fix has to live where the candidates are gathered. Camoufox describes its WebRTC handling as IP spoofing implemented at the protocol level rather than in JavaScript, which is the only place it can work; a JavaScript override of the RTCPeerConnection API does not stop the native ICE gathering that already happened underneath it. This is the clearest case for why the engine is the right layer. The leak is native, so the patch has to be native.

The general lesson across fonts and WebRTC is that the fingerprint surface is wider than the JavaScript API surface. Some of it is network behavior, some is protocol-level, and some, like the TLS ClientHello and the HTTP/2 frame ordering, sits entirely outside the browser engine’s fingerprint-spoofing reach and has to be handled separately by the network stack. An anti-detect browser that perfectly spoofs canvas, WebGL, audio, and navigator, and then connects with a TLS fingerprint that says “this is headless Chromium driven by a Go HTTP library,” has lost at a layer the engine patches never touched. Crawlex has covered that network surface in TLS fingerprinting from ClientHello bytes to JA4, and it is the standing reminder that the engine is necessary but never the whole job.

Where the recompiled engine still leaks

A patched browser closes the JavaScript seam. It does not make the browser undetectable, and the honest version of this post has to say where it still loses.

The first leak is internal incoherence, the same enemy as always, now harder to spot because there is no shim to find. A native patch that returns a Windows user agent, an Apple GPU renderer string, the macOS system font set, and a timezone that does not match the proxy’s geolocation has produced a fingerprint that is individually native at every property and collectively impossible. Detection vendors increasingly score the joint distribution rather than any single value, asking whether this combination of signals has ever been seen on a real device. The engine patch guarantees each value looks native. It does not guarantee they belong together, and that coherence has to come from the profile-generation layer, which is why the BrowserForge-style sampling from real device distributions matters as much as the C++ patches do.

The second leak is the rendered-output problem on canvas and WebGL. Spoofing the WebGL vendor and renderer strings is cheap and the engine patch does it cleanly. Making the actual rendered pixels of a 3D scene match the claimed GPU is not cheap, because the pixels come from the real GPU and driver doing real work. A detection script that renders a known scene, reads it back, and checks whether the pixel signature is consistent with the claimed renderer is checking the one thing the metadata spoof cannot fix. The Skia anti-aliasing patch shifts the 2D canvas signature, but a determined cross-check between the claimed hardware and the observed rendering is a live weakness, and not a fully solved one.

The third leak is the build itself. A custom-compiled browser is still a specific binary with specific quirks. Compiler flags, included and excluded features, the exact set of supported codecs and media capabilities, the precise behavior of timing APIs, the version string and its consistency with the actual engine behavior. A patched Firefox that claims to be a stock Firefox of a given version, but behaves in some measurable way that no stock Firefox of that version ever did, has a tell that is independent of any single spoofed property. Castle’s anti-detect teardown found exactly this class of artifact in the browser it analyzed: a mutation observer that fired characteristic functions when an iframe was inserted, surfacing the tool’s own machinery in a stack trace. The deeper the patch, the rarer this gets, but it never reaches zero, because the patched browser is not the browser it claims to be, and a sufficiently patient detector can find the difference. Crawlex’s piece on why stealth plugins lose and the companion teardown of Camoufox’s patched-Firefox approach both circle this same gap from the property-patching side; the engine-level approach narrows it but does not erase it.

What the layer buys and what it does not

The engine is the right place to spoof a fingerprint for one reason that the rest of this post keeps returning to: it removes the JavaScript seam. There is no wrapper to stringify, no getter on the wrong object, no per-realm race between the worker and the patch, no obfuscated function source waiting in a stack trace. The value comes back through the same native path a real browser uses, and from the page’s side it is indistinguishable from a real read because, mechanically, it is one. That is a genuine and large advantage over the plugin approach, and it is why the serious tools ship a compiled binary rather than an init script.

What the layer does not buy is coherence, and coherence is where modern detection has moved. A vendor that scores the joint distribution of a few hundred signals does not need to catch your toString; it needs to notice that no real machine has ever presented this exact combination, or that your rendered WebGL pixels disagree with your claimed GPU, or that your TLS handshake says something your navigator denies. The engine patch makes each signal locally honest. The profile behind it has to make them globally consistent, and the network stack underneath has to agree with both. Miss any of those and the native-ness of each individual value buys you nothing.

The cleanest way to see the whole thing is to notice what the two camps share. Brave farbles to make a real human look different to every site; an anti-detect browser patches the same surfaces to make a synthetic identity look the same to one site over and over. Mozilla normalizes to make everyone look identical. Three goals, one toolbox, and the same hard constraint underneath all of them: the only fingerprint a detector cannot use is one that is stable when it should be stable, varied when it should be varied, and coherent in every direction at once. Patch the engine and you can hit the first two. The third is still won or lost above the C++, in the profile, and that is the part no recompile can do for you.


Sources & further reading

Further reading