liarjs.dev
← Field notes
Automation

Automation tells are orthogonal: CDP, Runtime.enable and the isolated world

August 7, 2026 · 12 min read

You can spend months perfecting a fingerprint kernel (deterministic canvas, a self-consistent WebGL parameter set, localized fonts, lieCount=0 on CreepJS) and still get blocked on the first request. The fingerprint didn’t leak. The driver did. Automation detection rides a separate axis from fingerprint spoofing, and a serious anti-bot stack joins the two judgments before it decides. This post covers that second axis: what it probes, why the deepest tell (Runtime.enable) is subtler than the obvious ones, and where things stand in the 2026-07 snapshot.

Fingerprint, automation and network detection are three separate axes

Two ledgers, joined at the end

Keep fingerprint spoofing and automation detection on separate ledgers, because they fail independently.

The fingerprint axis asks: does this browser look like a real device? Canvas, WebGL, audio, fonts, timezone, screen metrics, the plugin face. A good engine-level kernel can win this axis outright.

The automation axis asks something else: is this browser being driven by a program? navigator.webdriver, a HeadlessChrome token in the User-Agent, empty navigator.languages, outerWidth=0, rtt=0, and the deep one: traces of the Chrome DevTools Protocol having enabled a Runtime domain. None of these care how good your canvas noise is. A kernel that fakes canvas, WebGL and audio perfectly can still shout that it is automated.

Real anti-fraud systems never weigh these in isolation. They join them. A perfect device fingerprint wired to navigator.webdriver=true reads as someone trying to pass, and that pairing often scores worse than either signal on its own.

Our own scanner assigns navigator.webdriver=true a weight of 40, a HeadlessChrome UA token 30, and empty navigator.languages 10. What it cannot do is see CDP itself. The protocol socket is invisible to JavaScript; what page JS can observe is the residue that enabling a domain leaves behind, and reading that residue reliably takes the specialized, version-tracking probes that sites like rebrowser ship. So the scanner covers the shallow end of this axis and defers the deep end to the dedicated bot detectors. A green automation section on a general fingerprint scanner does not mean the driver is clean.

The obvious tells

Playwright and Puppeteer commonly leave navigator.webdriver=true. That is the single most probed automation property on the web, and it is one line to check:

// The zeroth-order automation tell. Vanilla Playwright/Puppeteer set this true.
if (navigator.webdriver === true) {
  // Automated. No fingerprint spoof touches this - it lives on a different axis.
}

Do not infer that flag from the driver’s name, though. Whether it is set depends on the launch configuration and the attach mode rather than on whether you installed a stealth patch, and the A/B further down measured webdriver missing on a connectOverCDP session with no patching whatsoever. This is the single most common way people mis-read a scan: they drive a real headed Chrome over CDP, the flag is off, nothing else is spoofed, the report comes back clean, and they conclude the scanner is broken. It measured correctly. There was no inconsistency to find. Measure the stack you actually ship instead of the one the folklore describes.

Headless mode historically stamped HeadlessChrome into the User-Agent, about as loud as a tell gets. Empty navigator.languages, window.outerWidth === 0 and navigator.connection.rtt === 0 are the classic headless smells, artifacts of a browser with no real window and no real network path.

How much do sites actually probe for these? The Bamberg crawl dataset gives concrete numbers:

Signal Share of sites probing it
navigator.webdriver 34%
At least one automation-only JS property 46%
Chromium-headless blocks firing on HTTP header signals alone 75%

That last row is the one people underestimate. Three-quarters of headless blocks fire on headers, before a single line of your fingerprint JavaScript runs. Accept-Language ordering, header casing, the presence or absence of Sec-Ch-Ua, HTTP/2 SETTINGS, pseudo-header order. You can win every JS-layer check and still never reach the page. Fixing the obvious JS tells is necessary and nowhere near sufficient.

The deep one: Runtime.enable and the execution-context leak

Here is the tell that separates people who understand this axis from people who copy stealth patches.

The Chrome DevTools Protocol is how Playwright and Puppeteer talk to the browser. To evaluate scripts, read the DOM and expose bindings, the driver historically calls Runtime.enable on the CDP session. Enabling the Runtime domain carries a side effect: the browser emits Runtime.executionContextCreated events and materializes a detectable execution context in the page’s main world. The page cannot see the CDP socket (that channel is invisible to JavaScript), but it can observe the residue that enabling Runtime leaves behind.

// Conceptual - the page cannot call CDP, but it can probe the side effects
// that Runtime.enable leaves in the main world.

// 1. A stack-trace / sourceURL that betrays an injected evaluation context.
try { throw new Error(); } catch (e) {
  // Driver-injected scripts can surface an unexpected sourceURL frame.
  probeStack(e.stack);
}

// 2. A binding/console side effect that only exists once Runtime is enabled
//    and something is exposed into the main world (exposeFunction leaks).
detectLeakedBinding();

// 3. Whether *our* probe itself ran in the main world with the context live -
//    the "mainWorldExecution" check.

rebrowser-bot-detector (repo rebrowser/rebrowser-bot-detector) is built specifically around this. Its checks (dummyFn, sourceUrl, exposeFunctionLeak, mainWorldExecution) are all facets of one question: did the driver enable Runtime, and did its scripts run where a detector can see them? This is the most fingerprintable CDP tell, and it is why a stealth patch that only hides navigator.webdriver is theater. The block was never about that flag.

CDP detection targets the underlying automation itself, including the tools that try to hide behind stealth patches.

(the defender view, Antoine Vastel / niespodd)

These detectors are not looking at your fingerprint. They are looking at the fact of automation, which stealth patches paper over at the wrong layer.

The isolated world and why it matters (2026-07 baseline)

Now the state of play, more favorable than the folklore suggests, with a large asterisk.

Modern Playwright injects its init scripts into an isolated world rather than the page’s main world. An isolated world shares the DOM but keeps its own JavaScript scope and execution context, distinct from the one a page-side detector can enumerate. When Playwright runs there, its activity never materializes in the main world where rebrowser’s mainWorldExecution probe looks.

We ran the A/B in the 2026-07-04 baseline (Chromium 149 kernel, a US/Win11 persona, headful throughout), comparing three driving modes against sannysoft and rebrowser:

Driving mode rebrowser runtimeEnableLeak mainWorldExecution sannysoft (58 rows)
Launcher (start URL as positional arg, no Runtime.enable) green ⚪️ neutral 0 red
Raw CDP with Runtime.enable=ON green ⚪️ neutral 0 red
Playwright connectOverCDP 1.61.1 green ⚪️ neutral 0 red

All three came back with zero red. On sannysoft: navigator.userAgent reported Chrome/149 (not HeadlessChrome), webdriver missing, window.chrome present, the modern 5-plugin PDF face intact. On rebrowser: runtimeEnableLeak, navigatorWebdriver and pwInitScripts all green, with mainWorldExecution coming back neutral (⚪️), not red, precisely because Playwright’s init scripts ran in the isolated world.

Two things made this work. First, the launcher starts the browser with the start URL as a positional command-line argument (cmd.arg(START_URL)) instead of navigating via CDP, and its self-check only polls HTTP /json/version and reads DevToolsActivePort. It never calls Runtime.enable. Second, when Playwright is in the loop, its connectOverCDP path drives through the isolated world. The earlier fear that Playwright always turns rebrowser red did not survive measurement in this configuration.

connectOverCDP vs launch

The distinction that matters:

// connectOverCDP: attach to a browser someone else launched.
// Init scripts run in an isolated world → does NOT trip rebrowser's
// mainWorldExecution / Runtime.enable leak in the 1.61.1 baseline.
const browser = await chromium.connectOverCDP('http://127.0.0.1:9222');

// launch(): Playwright owns the process and its CDP setup.
// Can re-introduce the Runtime.enable execution-context leak.
const browser = await chromium.launch({ headless: false });

connectOverCDP against a browser you launched yourself (start URL as a positional arg, no driver-side Runtime enabling) is the clean path in this snapshot. chromium.launch() hands the CDP lifecycle to Playwright and can bring the leak back.

This is version-coupled, treat it as a CI matrix, not a conclusion

The green result above is tightly coupled to the Playwright version (1.61.1) and the driving mode. Switch the client, bump the version, or move from connectOverCDP to chromium.launch(), and the Runtime.enable leak can reappear. Isolated-world behavior is an implementation detail of the driver, not a contract, and it has changed before.

So this belongs in a per-version CI regression matrix, not a one-time claim that you pass rebrowser. Every change to the driving stack (new Playwright, new Chromium, a different attach mode) has to re-run rebrowser plus sannysoft and assert the gates. Pin the versions you have measured. Re-measure when you move.

Where the stealth tooling stands

undetected-chromedriver strips the obvious tells but leaves CDP and automation residue intact, and by 2026 it is considered behind. Some reports put it below 10% pass rate against Cloudflare’s Pro tier, and its own author has moved on to nodriver. That trajectory is the tell. More patches on top of Selenium will not fix CDP residue; driving the browser differently will. nodriver and Patchright aim to minimize CDP residue at the source rather than mask it after the fact, the same principle as the launcher approach above: do not enable Runtime, and do not navigate through the channel a detector can observe.

Verify against the sites that actually probe this axis, not just a general fingerprint scanner:

Why name real walls like nowsecure.nl and incolumitas separately from sannysoft? sannysoft tells you whether the shallow tells are clean; the walls tell you whether the joined judgment (fingerprint plus automation plus network) lets you through. Those two can disagree.

Takeaway

The fingerprint kernel and the driver are two separate ledgers, and anti-bot systems read both before they decide. Fix navigator.webdriver, the HeadlessChrome token and the empty-languages/rtt=0 smells first. 34% to 46% of sites probe them, and 75% of headless blocks fire on headers alone. Then take the Runtime.enable execution-context leak seriously: it is the deepest CDP tell, out of reach for a general fingerprint scanner but not for rebrowser’s dedicated probes. In the 2026-07 snapshot, a launcher that never enables Runtime plus Playwright connectOverCDP 1.61.1 running in the isolated world stays green on rebrowser and sannysoft, but that is a version-pinned measurement, not a law. Put it in a regression matrix and re-run it every time the driving stack moves.

Update (2026-08): the “clean CDP session” case above is now provisional, not a pass

The A/B in this post reached one conclusion that this site used to repeat as its own position: a real headed Chrome, driven via connectOverCDP with no stealth patching, comes back with zero red across sannysoft and rebrowser, and a high score from a passive fingerprint scanner on that session is correct, not a false negative. We are not deleting that conclusion. It still holds, under the premise it was measured under: passive, JS-observable probes only. If the only instruments you run are navigator.webdriver, UA tokens, navigator.languages, outerWidth, and rebrowser’s execution-context leak checks, then a clean connectOverCDP session genuinely has nothing for them to find. That was never a bug in the measurement. It was the ceiling of what passive measurement can see.

What changed is that we stopped treating that ceiling as the last word. liarjs now adds a second, orthogonal layer: a behavioural challenge (src/lib/behaviour/) that the visitor has to actually engage with - a Joker card that has to be found and clicked through real pointer motion, not just a DOM click event. This layer does not replace the passive checks in this post; it sits on top of them and asks a question passive JS properties structurally cannot answer: is there a human moving this pointer right now? The result collapses to one of three driver verdicts - human, synthetic, no-signal - via applyDriverVerdict(). A synthetic verdict (what a Playwright-driven session produces when it clicks the challenge programmatically) caps the score at min(score - 45, 39) regardless of how clean the passive layer reads. The exact clean-connectOverCDP-headed-Chrome scenario from the A/B above, if driven through the challenge by Playwright rather than a person, now scores in the Caught you range - not because the fingerprint or the CDP residue changed, but because a layer this post’s A/B didn’t have access to caught the thing the A/B correctly said passive probes couldn’t.

The blind spot does not close cleanly, and it would be dishonest to imply it does. A session that never engages the challenge at all - never clicks Joker - gets no-signal, not synthetic. no-signal is not penalized; the score stays exactly where the passive layer left it, capped at a provisional verdict (Unverified · driver unchecked) rather than pushed down. That is a deliberate design choice, not an oversight: refusing to play the challenge is not evidence of automation, so it does not get scored as if it were. But it also means a sufficiently disciplined synthetic driver that simply never touches the challenge is indistinguishable, by this site’s own scoring, from a real visitor who got bored and left. The behavioural layer catches automation that plays and loses; it has no opinion on automation that declines to play. And it says nothing about a human on a machine that behaves oddly for unrelated reasons - a screen reader user, someone with a tremor, an unusual input device - all of whom risk landing on synthetic or no-signal through no fault of authenticity. The Error.stack CDP tell from the deep-tell section above still doesn’t fire on Chrome 151 either; that gap is unchanged by any of this.

Sources & further reading
  1. rebrowser-bot-detector
  2. bot.sannysoft.com
  3. bot.incolumitas.com
More field notes
← Back to field notes