How anti-bot systems fingerprint the JavaScript runtime
Most writing about browser fingerprinting fixes on the things that produce a stable identifier: the canvas hash, the WebGL renderer string, the audio-stack readback, the list of fonts. Those answer a different question. They ask which machine is this, for tracking and deduplication. Anti-bot systems care about a question one layer up. Before they ask which machine, they ask whether the thing executing their JavaScript is the runtime it claims to be. A real Chrome on a real laptop, or a headless build wearing Chrome’s user-agent string, or a Node process pretending to be a browser at all.
That question gets answered by the runtime itself, not by the hardware underneath it. The script the anti-bot vendor ships runs inside V8, or SpiderMonkey, or JavaScriptCore, and every engine leaks its identity through a hundred small behaviours that nobody standardized. How an error formats its stack. What a function prints when you stringify it. Which order properties come back in. How a Proxy behaves when a debugger is watching. These are not configuration values you can set. They are emergent properties of a specific build of a specific engine, and that is exactly what makes them useful for catching a lie.
This post walks the runtime-probe surface that detection scripts actually exercise. We start with error stacks, because their format is openly implementation-defined and the spec says it will stay that way. Then Function.prototype.toString and the native-code question, which is where stealth patching gets caught. Then property enumeration order and the small set of cases where it is observable. Then feature and timing probes that pit the user-agent against measurable behaviour. Then the CDP side channels that turned the error object into a tripwire for automation, and the V8 change in 2025 that quietly disarmed the most famous one. Throughout, the line to hold is that these checks do not need to be individually decisive. They need to be cheap, numerous, and mutually corroborating.
The stack trace as an engine signature
error.stack is one of the most-used non-standard features in the language. Every engine implements it. None of them agree on what it should look like, and the committee in charge has decided not to make them agree.
Take a function baz called from bar called from foo, all throwing from the same file. V8, the engine in Chrome and Node, produces a multi-line string whose first line is the error’s class and message, followed by frames each prefixed with the literal token at:
Error at baz (filename.js:10:15) at bar (filename.js:6:3) at foo (filename.js:2:3) at filename.js:13:1SpiderMonkey, in Firefox, drops the header line entirely and uses an @ to separate the function name from the location:
[email protected]:10:15@filename.js:13:1JavaScriptCore, in Safari, also uses the @ form but diverges in the small things: a different column number for the same source position, and the literal token global code where SpiderMonkey leaves the function name blank.
[email protected]:10:24global [email protected]:13:4A detection script never has to reason about all three. It throws a controlled error, reads error.stack, and asks one yes/no question: does this string start with an error-class header and use at frames? If yes, the runtime is V8-family. If the user-agent says Safari but the stack reads like V8, the two disagree, and disagreement is the whole game. A genuine Safari cannot produce a V8 stack. A headless Chromium driving a spoofed user-agent produces a V8 stack every time, because the string comes from the engine, far below anything navigator exposes.
The format is only the first axis. The property’s shape is the second. In V8 the stack property is materialized lazily and behaves like a data property on the error instance: writable, non-enumerable, configurable. In SpiderMonkey it is an accessor sitting on Error.prototype rather than the instance. That difference is directly observable through Object.getOwnPropertyDescriptor, with no need to parse any text. Whether stack lives on the instance or the prototype, whether it presents as get/set or as value, is another fork in the tree that a spoofed user-agent cannot paper over.
The reason this is durable, rather than a quirk that will get smoothed away, is on the record. The TC39 Error Stacks proposal sits at stage 1 and is explicit that it standardizes the API (that stack exists, roughly what it returns) while leaving the textual format implementation-defined. The proposal’s own framing is to capture the intersection of what engines already do without forcing them to converge on one string layout. So the format divergence is not a transitional accident. It is a designed-in degree of freedom, which means it will keep working as an engine discriminator for as long as the proposal stays on its current path.
V8 carries one more tell that the others lack outright: Error.stackTraceLimit. It is a mutable numeric property that caps how many frames get captured, and it is a V8 invention. Firefox and Safari do not expose it. Reading whether Error.stackTraceLimit is a number or undefined is a one-line engine check, and it appears as exactly that in detection inventories.
Function.prototype.toString and the native-code question
The single most important runtime probe is also the oldest. Function.prototype.toString, called on a built-in, returns a string whose body is the placeholder { [native code] } rather than real source. Call it on a function written in JavaScript and you get the actual source text back. That asymmetry is the lever the entire stealth-versus-detection contest pivots on.
Anti-bot scripts use it because automation frameworks have to override native functions to hide. A stealth layer that wants navigator.webdriver to read false, or wants navigator.plugins to look populated, has to replace a getter or wrap a built-in. The moment it does, stringifying the replacement risks printing JavaScript where [native code] belongs. So the detector calls toString on the suspicious function and checks for the native marker.
The countermeasure is to patch toString itself so that, for the wrapped function, it returns the original native-looking string. Now the detector escalates. It does not trust toString to describe other functions. It interrogates toString about itself: Function.prototype.toString.call(Function.prototype.toString). A genuine engine returns the native marker for its own toString. A stealth layer that monkey-patched toString to lie about other functions has, by doing so, made toString itself non-native, and now it must also lie about itself, recursively, without leaving a seam. Real-world bot-detection inventories check exactly this self-referential case as a distinct probe, separate from the per-function checks on setTimeout, setInterval, XMLHttpRequest.prototype.open, XMLHttpRequest.prototype.send, and Permissions.query.
There is a quieter variant that does not parse the body at all. The length of the stringified function. In Chromium, eval.toString().length is 33; in Firefox it is 37. These numbers are stable because they fall out of how each engine renders the canonical source of eval. Third-party detection tools fold this into a small table of expected lengths per engine, and a mismatch between the measured length and the user-agent’s claimed engine is another inexpensive flag. Some inventories extend the table to other runtimes with their own byte counts; the exact figure for any given engine should be measured rather than trusted from a secondhand list, because builds drift, but the principle (that the length is engine-determined and the user-agent is not) is what the check rests on.
The deeper point is architectural. Every JavaScript-level override is detectable in principle, because the act of overriding leaves a trace somewhere in the object graph: a descriptor that should say value but says get, a prototype that gained a link it should not have, a function whose source no longer reads as native. The only override that is genuinely invisible to a script is one performed in the engine’s C++ before any JavaScript runs, because from inside the language a C++-backed function is indistinguishable from any other native. That is the reason the more serious anti-detect projects moved away from injecting JavaScript patches and toward shipping modified browser binaries. The runtime fingerprint forced the move. This same logic plays out in the device-fingerprinting layer, where the question shifts from is this patched to is this stable; the entropy-budget tradeoff there is its own subject, covered in device fingerprinting in anti-bot stacks.
Property enumeration order, and where it is actually observable
There is a popular belief that JavaScript object property order is a rich fingerprinting source. It mostly is not, and understanding why is useful for knowing where it does bite.
Since ES2015, and tightened across the board by ES2020, the order of own property keys is specified. The [[OwnPropertyKeys]] internal operation returns integer-index keys first in ascending numeric order, then string keys in insertion order, then symbol keys in insertion order. Object.keys, for...in, Object.getOwnPropertyNames, and Reflect.ownKeys all observe this. So for an object you build yourself, every modern engine returns the same order, and there is nothing to fingerprint. The specification closed the gap on purpose.
The fingerprint survives in two places the spec never fully nailed down. The first is the historical edge: integer-like keys get hoisted and sorted ahead of string keys, which means an object whose keys mix numeric strings and words enumerates in an order that surprises anyone expecting pure insertion order. That behaviour is now uniform, so it discriminates old versus new far more than engine versus engine. The second, and the one that still matters, is the ordering of keys on host objects and built-ins that the language never specified key-by-key. The exact sequence of properties on navigator, on a freshly constructed DOM node, on the window object, comes from each engine’s own internal layout rather than from a portable rule. Enumerate navigator’s own keys and the order is a property of the Chrome build, the Firefox build, the Safari build. A spoofed environment that adds or reorders properties to fake a different browser has to reproduce that native ordering exactly, and the native ordering is not written down anywhere it can copy from.
This is why property enumeration shows up in detection less as a standalone identifier and more as a consistency cross-check. The detector does not need the order to be unique. It needs the order on navigator to match what the claimed browser actually produces, and to match the other signals the same browser would produce. A fingerprint that claims Safari but enumerates navigator in Chrome’s order has contradicted itself, and the contradiction is the finding.
Feature probes and the user-agent contradiction
A large class of runtime checks does nothing more sophisticated than ask whether a feature exists and then compare the answer against the user-agent’s story. The premise is that the user-agent string is trivially editable while the presence or absence of an API is not. So you build a small matrix: the browser the headers claim to be, and the features that browser must and must not have.
The Chrome-shaped checks are the densest. A real Chrome exposes a window.chrome object carrying loadTimes, csi, runtime, and app. Many headless or spoofed environments expose a hollow window.chrome missing those members, or omit it while still claiming Chrome in the user-agent. Detectors probe each member. They check navigator.pdfViewerEnabled, the presence of RTCPeerConnection, the behaviour of navigator.mediaDevices.enumerateDevices, the existence of navigator.brave.isBrave to catch a Brave masquerading as vanilla Chrome. None of these is decisive alone. Together they sketch a profile, and the profile either coheres with the claimed browser or it does not.
navigator.webdriver belongs in this group as the bluntest instrument. The WebDriver specification requires conforming automation to set it to true, so a stock Selenium, Puppeteer, or Playwright session announces itself directly. Stealth layers suppress it, most cleanly by launching with --disable-blink-features=AutomationControlled, which removes the flag at the source rather than patching it after the fact. The crude patch (deleting or overwriting the property in JavaScript) is itself detectable, because the deletion leaves the property present-but-undefined or leaves a descriptor that does not match a genuine absence. So the modern check is less “is webdriver true” and more “does the shape of webdriver’s absence look like a browser that never had automation, or like a browser that had it scrubbed.”
The clean-launch question pushes the contest below the JavaScript layer entirely, and that is where it has largely gone. Once a headless build no longer leaks webdriver, no longer ships a hollow window.chrome, and carries a plausible user-agent, the surviving runtime tells are subtle, and the decisive signals move to the network and protocol layers. DataDome’s research arm put a date on the inflection. After the new headless Chrome shipped (the --headless=new mode that shares the standard Chrome codebase), a spoofed user-agent plus --disable-blink-features=AutomationControlled left very few fingerprint inconsistencies to find, which is what pushed detection toward CDP side effects and toward signals that live outside the runtime altogether, like TLS and HTTP/2 fingerprints. The runtime probes did not stop mattering. They stopped being sufficient on their own.
A few engine quirks survive purely as arithmetic. The IEEE 754 floating-point results of certain operations differ across math-library implementations, so values like Math.tan(-1e308) or Math.acos(1.0001) come back with engine-specific bit patterns, and 1/-0 distinguishes a correct negative-zero implementation from a broken one. These are stable, cheap, and impossible to fake without reimplementing the math library, but they discriminate at the level of math-library-and-platform rather than catching automation directly. They are corroborating signals, not headline ones.
Timing as a probe
performance.now() and friends give a script a clock, and a clock is a probe in two directions. The coarse direction measures resolution. Browsers deliberately reduced timer precision after the Spectre disclosures, clamping and jittering performance.now so a script cannot use it to mount a side-channel attack. The exact clamp differs by browser and by cross-origin-isolation state, so the observed resolution is itself a weak engine-and-configuration signal. A runtime that returns timer values at a precision no shipping browser offers has contradicted its user-agent.
The fine direction measures behaviour under instrumentation. Some operations run measurably slower when a debugger or an automation protocol is attached, because the instrumentation imposes per-call overhead. Tight loops timed with performance.now, or the wall-clock cost of triggering a debugger statement, can reveal that something is watching, even when the watcher leaves no API footprint. This shades into anti-instrumentation proper, which the Kasada anti-instrumentation write-up covers as a discipline of its own. The general shape is that a runtime under a tool is not a runtime at rest, and timing is one of the few ways a script can feel the difference from inside.
Timing also feeds the network-shaped probes in the same inventories. A headless environment that has not set up a realistic network stack can leave navigator.connection.rtt and navigator.connection.downlink reading exactly zero, and chrome.loadTimes().firstPaintAfterLoadTime reading zero, where a real browsing session produces non-zero figures. These are not runtime-engine signals in the strict sense, but they ride the same script and feed the same verdict, so detection inventories list them alongside the engine probes.
The console as a CDP side channel
The most interesting runtime probe of the last few years did not look at the runtime at all. It looked at what happens when a debugger is attached to it. This is the story of how the humble error object became a tripwire for Chrome DevTools Protocol automation, and how a V8 change in 2025 quietly took the tripwire away.
Puppeteer, Playwright, and Selenium drive Chromium over CDP. To run code in a page they need an execution-context id, which they obtain by sending the Runtime.enable command. That command switches on the Runtime domain, and a side effect of the Runtime domain being enabled is that the browser serializes objects passed to console methods so the (non-existent, in automation) DevTools front end can render previews. That serialization is observable from inside the page, and the observation became a clean automation check.
The mechanism is a getter trap. A script creates an error, defines a stack accessor on it that flips a flag when read, and passes the error to console.debug. If the Runtime domain is enabled, the browser walks the object to build its console preview, reads stack, fires the getter, and the flag flips. No CDP client attached, no serialization, no getter call, flag stays down.
DataDome’s research arm publicized this CDP-side-effect approach in June 2024 as a response to the new headless Chrome erasing the older fingerprint gaps. The detection was clean because the false-positive rate was near zero: a normal user’s browser, with DevTools closed, never has the Runtime domain enabled, so the getter never fires for them. Automation that needed page.evaluate had to send Runtime.enable to get a context id, so it lit the tripwire by construction. The bot-development response was to stop relying on the command. Tools began using Page.createIsolatedWorld to obtain a context id without the global Runtime-domain side effect, or sending Runtime.enable immediately followed by Runtime.disable to shrink the window, and a wave of CDP-minimal frameworks (nodriver, Selenium driverless among them) grew up around avoiding the signal.
Then the ground moved underneath both sides. In May 2025, V8 changed how its inspector reads error properties during console serialization. The inspector’s path through value-mirror.cc that called Get() on a console argument’s stack (and thereby invoked user getters) was wrapped so it no longer fires user-defined getters in that position; the patches gate the read behind a script-id check. The practical result is that the canonical version of the trick (plant a getter on .stack, log the error, watch the getter) stopped firing, because the side effect it depended on was deliberately removed. A classic, widely deployed signal degraded not because anyone defeated it but because the engine stopped exhibiting the behaviour the signal read. Detection vendors who had wired the check in as a strong indicator had to notice it had gone quiet and reweight.
The story is not closed. Independent research published in early 2026 showed that the same family of side effect survives in adjacent paths the May 2025 patch did not cover. The console still builds previews for its arguments, and that preview construction still walks the object, including its prototype chain. A Proxy planted on an object’s prototype with an ownKeys trap can detect the walk: pass such an object to a console method, and if the Runtime domain is active the preview machinery enumerates keys, hits the trap, and the trap fires. The patch closed the specific .stack getter door; the broader behaviour of serializing console arguments left other doors. There is also a reported gap in the patch itself, where a failure in the descriptor lookup falls back to a direct Get() before the script-id check applies. The exact reach of these residual paths across Chromium versions is not something the public write-ups settle definitively, and a detector relying on any one of them is relying on current engine internals that the engine can change again without warning.
That volatility is the real lesson of the CDP saga. A network-layer fingerprint like JA4 changes on the timescale of a TLS-library release. A runtime side channel like the console getter can change on the timescale of a single V8 commit, land in a Chrome stable in weeks, and silently invalidate a detection rule that a vendor thought was load-bearing. The runtime is the most expressive fingerprinting surface and the least stable one, and those two facts are the same fact.
How the probes combine
No single probe in this post is a verdict. The stack format tells you the engine family. The native-code checks tell you whether built-ins have been tampered with. The enumeration order tells you whether host objects match the claimed browser. The feature matrix tells you whether the API surface is coherent. The timing and CDP side channels tell you whether something is instrumenting the runtime. A detection script fires dozens of these in a few milliseconds, collects the answers, and looks for the thing that no real browser produces: a contradiction. A user-agent that says Safari over a V8 stack. A window.chrome that is present but hollow. A navigator.webdriver whose absence has the wrong shape. An eval.toString().length that does not match the engine the headers claim.
The verdict from those contradictions is rarely rendered in the browser. The runtime probes serialize into a payload, the payload goes to a server, and the score that decides whether to challenge or block is computed there, where the rules can change without shipping new client code. That division of labour is its own topic, treated in server-side vs client-side bot detection. What the runtime layer contributes is a dense bundle of mutually constraining facts that a spoof has to satisfy all at once, in a runtime it does not fully control, against an engine whose behaviour it cannot fully predict.
That is why the contest keeps moving downward. JavaScript-level stealth lost to native-code probing, which forced patched binaries. Patched binaries closed the easy feature gaps, which forced detection toward CDP side effects. CDP side effects got patched in V8, which scattered the signal into harder-to-reach paths and pushed serious detection toward the network layer where the fingerprint changes on a slower clock. Each turn of that wheel is a few lines of probing code on one side and an engine commit or a binary patch on the other, and the wheel turns a little faster every cycle. The most durable thing in the whole exchange is the oldest one. An error, thrown on purpose, read for the shape of its own stack, telling you which engine you are really talking to no matter what the headers say.
Sources & further reading
- MDN Web Docs, Error.prototype.stack — exact stack-format examples for V8, SpiderMonkey, and JavaScriptCore, plus the data-property vs accessor distinction.
- TC39 (Harband, Miller), Error Stacks proposal — stage-1 proposal standardizing the stack API while explicitly leaving the textual format implementation-defined.
- TC39, Ordinary Own Property Keys / for-in order — the specified enumeration order: integer indices ascending, then strings and symbols in insertion order.
- Stefan Judis (2020), Property order is predictable in JavaScript objects since ES2015 — how ES2015/ES2020 closed the enumeration-order gap for user objects.
- Antoine Vastel / Castle (2025), From Puppeteer stealth to Nodriver — toString native-code probing, proxy detection, and the shift from API-level patching to protocol-level evasion.
- Antoine Vastel / Castle (2025), Why a classic CDP bot detection signal suddenly stopped working — the May 2025 V8 change that disarmed the console-getter CDP signal.
- sveba (2026), How V8 Leaks Your Headless Browser’s Identity — the
.stackgetter and prototype-ProxyownKeysconsole side channels, with the V8 inspector internals and the patch’s residual gap. - Antoine Vastel / DataDome (2024), How New Headless Chrome & the CDP Signal Are Impacting Bot Detection — why
--headless=newplus a spoofed UA erased older fingerprint gaps and pushed detection toward CDP side effects. - Rebrowser (2024), How to fix Runtime.Enable CDP detection — the
Runtime.enable/Runtime.consoleAPICalledmechanism and the isolated-world and enable/disable context-id workarounds. - HackingLZ, fingerprint_js — an annotated inventory of runtime probes: toString integrity,
Error.stackTraceLimit,eval.toString().length, IEEE-754 math quirks, and the automation-artefact globals. - Nikkhah Bahrami, Cutler, Bilogrevic (2025), Byte by Byte: Unmasking Browser Fingerprinting at the Function Level Using V8 Bytecode Transformers — CCS ‘25 paper showing V8 bytecode retains fingerprinting structure through obfuscation, at 98.9% function-level accuracy.
Further reading
Headless Chrome detection: every tell from navigator.webdriver to missing codecs
A reference catalog of the signals that give headless Chrome away: the webdriver flag, empty plugin lists, the permissions contradiction, missing proprietary codecs, software WebGL renderers, and what --headless=new actually fixed.
·21 min readThe HeadlessChrome user-agent token and the long tail of headless signals
Traces the HeadlessChrome user-agent token from its 2017 origin through the 2023 --headless=new rewrite, and the second-order tells that survive a clean UA: permissions inconsistencies, software WebGL, missing codecs, and CDP side effects.
·20 min readHow puppeteer-extra-plugin-stealth works, patch by patch
A walkthrough of the individual evasions in puppeteer-extra-plugin-stealth: the webdriver flag, chrome.runtime, the permissions contradiction, plugins and mimeTypes, the WebGL vendor, and iframe.contentWindow, with what each patch fixes and where it leaks.
·21 min read