The Runtime.enable serialization tell
When an automation client sends the Runtime.enable command, Chrome starts forwarding console activity back to the controller. To do that, it has to serialize the arguments passed to console.log and similar calls — that is, convert them into data that can cross the protocol boundary (the gap between the page and the tool driving it). That conversion is observable: if you log an object that has a getter (a function that runs when a property is read) on one of its properties, the act of serializing the object invokes the getter — even though no human ever opened DevTools.
So the probe is tiny: create an object whose id (or toString, or a numeric coercion) is a getter, console.log it, and check whether the getter ran. On a normal browser nothing reads the property and the getter never fires. Under CDP with Runtime.enable active, the getter fires — revealing the automation channel. This is the same kind of Error.stack-style trap that catches frameworks which keep Runtime enabled by default.
Other CDP leaks
Beyond Runtime.enable, CDP-driven automation leaks in several other ways:
- Console.enable - similar to Runtime; turning it on changes how console objects are handled, which a page can probe for.
- Framework bindings - the tools leave their own fingerprints in the page. Playwright injects
window.__playwright__binding__/__pwInitScripts; older Puppeteer and ChromeDriver leave their own global variables (cdc_properties). These are leftover artifacts of the CDP control channel exposing functions to the page. - Timing and event anomalies - mouse and keyboard events dispatched through CDP can lack the trusted-event properties and natural timing of input from a real person.
- toString inspection - any JavaScript a CDP driver injects to hide itself can itself be inspected and exposed.
The common thread: the control channel, and the scripts it injects, live in or affect the page's own scope — so a sufficiently paranoid anti-bot script can find them.
Reducing the CDP surface
Ways to shrink these tells, roughly from easiest to most robust:
- Do not enable
Runtime/Consoleunless you actually need them, and delete the framework bindings in an init script that runs before the page loads (delete window.__playwright__binding__; delete window.__pwInitScripts;). This removes the cheapest tells. - Hook earlier / lower - run your automation logic in a privileged context before the page navigates, rather than injecting code into the page's scope, so there is less for the page to observe.
- Patch the engine - anti-detect browsers suppress the console serialization side effect and hide the bindings down in the C++ source, so
Runtime.enableno longer changes anything the page can see.
The reason CDP detection matters so much is the same theme that runs through all fingerprinting: a perfect static fingerprint does not help if the way you are driving the browser betrays you. Real-browser drivers and managed APIs that minimise the CDP footprint exist precisely because the control channel is its own detection surface.
The four observable side effects of a live debugging session
A debugging protocol attached to a browser is not itself visible to page JavaScript, but several of its side effects are. Each corresponds to a real capability the controller needs, which is what makes them awkward to remove.
- Serialisation on error construction - when the protocol's runtime domain is enabled, constructing an
Errorand accessing itsstackcauses the browser to prepare a description for the debugging client. The extra work is measurable, and property accessors placed on a thrown object can observe the browser reading them - something no page script would do. - Injected source markers - script the controller evaluates in the page is often tagged with a
sourceURLso it appears sensibly in a debugger. Those markers can surface in stack traces captured by page code, naming the tool that produced them. - Exposed bindings - functions the controller exposes so page code can call back into it appear as properties on the global object that no browser ships. They are ordinary JavaScript functions, so they also fail native function integrity checks.
- Main-world execution - script evaluated into the page's own realm shares a global with page code. A page can plant a canary value and watch for anything unexpected reading or writing it, or simply enumerate globals against a known baseline for the browser version it claims to be.
The common thread is that these are consequences of the controller doing its job, not of sloppy implementation. A tool that needs to evaluate script in the page realm will be observable in the page realm.
What protocol control does not give away
It is equally important to be precise about what a debugging protocol does not expose, because a lot of effort gets spent on the wrong signals.
Input injected through the protocol is genuinely trusted. It enters below the JavaScript layer, at roughly the point the operating system would deliver it, so the engine marks the resulting events with isTrusted === true and grants user activation normally. A page cannot distinguish protocol-driven input from a physical mouse on the provenance flag alone - the distinction has to come from the ordering, dwell, and approach signals covered in input provenance detection.
Similarly, the protocol connection is a WebSocket between the controller and the browser process. Page JavaScript has no access to it, cannot enumerate it, and cannot see the commands flowing across it. Everything observable is downstream: the state changes the commands cause, and the artefacts they leave in the page realm.
That distinction points at the mitigation. Most of the visible surface comes from evaluating controller script in the page's own realm, so drivers that do their work in an isolated world - a separate realm with the same DOM but a different global - remove the binding and canary signals structurally rather than by hiding them. It also explains why realm coherence and protocol detection keep meeting: both are ultimately questions about which realm a piece of code is running in and what it can therefore observe or be observed by.
For collecting public web data the practical route is to reduce how much of the work needs a live debugging session at all. Managed web data infrastructure keeps the driving layer off the page realm and returns rendered output over an API, so the page never has a controller sharing its global to notice.
