The toString check and why it is not enough
Every built-in function in a browser prints the same shape when converted to a string: function getParameter() { [native code] }. A function written in JavaScript prints its source instead. That is the oldest test for a spoofed API: replace WebGLRenderingContext.prototype.getParameter with your own function and its source gives you away.
The countermeasure is equally old. A shim wraps Function.prototype.toString itself, or uses a Proxy whose target is the original native, so the hooked function prints a native-looking string. Against a page that only reads the string, that is enough, which is why the string alone has not been a serious check for years. The toString Q&A covers the basic mechanism.
What replaced it is a set of questions asked around the string: questions the shim has to answer too, and which most shims were never written to handle.
Asking around the string
A second realm. A fresh about:blank iframe has its own Function.prototype.toString, created by the engine moments ago. Calling it on the parent's functions sidesteps a shim installed on the parent's toString. The mechanics are covered in cross-realm coherence.
The descriptor. Native accessors live on prototypes. navigator.plugins is a getter on Navigator.prototype, not a property of the navigator object. A spoof that defines plugins directly on the instance leaves an own property where the engine never puts one, and Object.getOwnPropertyDescriptor(Navigator.prototype, 'plugins').get still points at the original. Deployed widgets read exactly that descriptor.
The error path. Natives throw specific errors with specific stacks when called with the wrong receiver. A widget's native-inspection probe reads stack from inside a toString call, and a wrapper adds a frame that a native call does not have.
Timing each call
The newest layer is time. One deployed widget converts about 150 natives and getters to strings, covering canvas, WebGL, WebGPU, audio, Intl, permissions, media, WebRTC, client hints, the keyboard API and every navigator getter it cares about, and records how long each call took, next to the result.
A native toString on a built-in is a fast path inside the engine. A shimmed one runs JavaScript: a Proxy trap, a lookup in a WeakMap of originals, a string build. Each step is small, but it happens on every call, and across 150 calls the overhead adds up to a pattern rather than a single noisy number. The clock limits the precision: Chrome exposes performance.now() in 100 µs steps, and some frames on other engines only in whole milliseconds, which is why the measurements are reported per call and compared as a profile rather than one by one.
Timing also explains why the same widget measures the clock resolution itself in a worker, with 5,000 pairs of readings. A timing profile only means something when the reader knows how coarse the clock was.
The console getter
One more check targets the automation channel rather than the spoof. The page creates an error, defines a getter on its stack property and passes the error to console.debug. In a normal browser nothing reads that property: the console message is never displayed, so the getter never runs.
When an inspector is attached and listening to console events, the browser serialises the message's arguments to send them over the protocol, and serialisation reads stack. The getter fires. DevTools does this; so does an automation client that enables the runtime domain to receive console messages, which is the side effect described in what is CDP detection. The same probe also runs debugger inside a worker with a timeout, which only pauses when a debugger is attached.
Neither check looks at a value the page could spoof. They observe whether something outside the page is watching.
The through-line
Each layer here exists because the previous one was patched. The string was faked, so detection read the descriptor; the descriptor was fixed, so it read a second realm; the realm was reached, so it timed the call. Every patch made in JavaScript adds code, and added code has a source, a location, a stack frame or a cost that native code does not have. The durable answer is to change the value where the engine produces it, so the function being inspected is the native one. That is the approach behind engine-level browsers such as Clearcote, described in how a stealth browser is built and verified.
For the wider set of checks that need no reference data at all, see spec invariants as lie detectors, and run the native-function rows yourself in the fingerprint test.
