Skip to content

Kasada's anti-instrumentation: how it detects CDP, Playwright, and patched runtimes

· 17 min read
Copyright: MIT
Wordmark reading anti-instrumentation with an orange underline under CDP

A Playwright script that has been carefully de-fingerprinted will pass most checks you can name. It reports navigator.webdriver as undefined. It carries a plausible user agent. Its canvas hash matches a real machine, its WebGL strings name a real GPU, its timezone agrees with its IP. And then it loads a page protected by Kasada and the session is dead on arrival, before the script has clicked anything, before there is any behaviour to score. The question that bothers people who run into this is simple: what did the page see that the fingerprint did not cover?

The short answer is that Kasada does not only ask what does this browser look like. It asks is this browser being driven, and those are different questions with different answers. A runtime can be made to look like Chrome 138 on macOS down to the byte. It is much harder to make a runtime stop behaving like a runtime that has a debugger attached to it. This post is about that second layer, the anti-instrumentation layer, and it is meant as a defensive reference rather than a bypass guide. It pairs with the companion piece on Kasada’s KPSDK token and VM, which covers the cryptographic token machinery the instrumentation signals eventually feed into.

The road map is short. First, where the anti-instrumentation layer sits inside Kasada’s request flow and why it runs before behaviour. Then the central technique of the last few years, Chrome DevTools Protocol detection, including the Runtime.enable side-channel and why it quietly broke in 2025. Then the framework-specific artifacts that Playwright, Puppeteer, and Selenium leave in a page. Then the harder problem of patched runtimes that lie about themselves, and how consistency checks catch a lie that a single property spoof cannot. A closing section on what this costs the defender and where the arms race sits now.

Where the instrumentation check sits

Kasada is delivered as an obfuscated client script, historically named p.js or ips.js depending on the deployment, that runs before the protected content is reachable. The script is not readable JavaScript. It is a custom bytecode interpreter, a small virtual machine, with the actual collection and proof-of-work logic encoded as bytecode the VM walks at runtime. That design matters here because it means the detection probes themselves are hidden. You cannot open the script, search for the string webdriver, and learn what is being checked, the way you can with a sensor script that ships as plain (if minified) JavaScript. The VM is the reason the exact field layout of Kasada’s payload is not public, and what follows about specific probes is inferred from observed traffic, from the published research on the underlying browser behaviours, and from Kasada’s own statements, not from a leaked schema.

The client collects signals, runs proof-of-work, and posts an encrypted payload to an endpoint of the form /<random>/tl. The server responds by issuing tokens carried on later requests as headers: x-kpsdk-ct, the expensive session token that proof-of-work earns, and x-kpsdk-cd, a cheaper per-request token derived from it. A request without valid tokens does not see protected content. The trust decision is a weighted score across stages, and instrumentation signals are some of the heaviest negative weights in that sum, because they are close to unfalsifiable. A residential IP can be rented. A canvas hash can be replayed. A live CDP connection to the browser is a fact about the runtime that is hard to hide while still driving it.

request flow, instrumentation signals feed the score before any behaviour p.js / ips.js VM collects CDP probes framework artifacts POST /…/tl encrypted payload score + token x-kpsdk-ct session token, proof-of-work earns it x-kpsdk-cd per-request token derived from the session key point: a CDP hit can sink the score on the very first request no clicks, no mouse path, no behaviour required *The instrumentation probes run inside the client VM and post their results before any user behaviour exists to measure.*

This is the part worth holding onto. Behavioural analysis, the mouse-path and timing scoring that gets the most attention, is a later and noisier layer. Instrumentation detection fires on the first page load. If it trips, nothing the script does afterward will recover the session, because the negative weight is already in the score.

The CDP signal

Every mainstream browser-automation framework that drives Chromium talks to it over the same channel: the Chrome DevTools Protocol. Puppeteer uses CDP. Playwright uses CDP for its Chromium target. Selenium’s ChromeDriver uses CDP underneath. CDP is the protocol the DevTools panel itself speaks, exposed over a WebSocket, and a framework connecting to it is doing the same thing a developer does when they open DevTools, just programmatically. So if a page can tell that a CDP client is attached, it has caught the common denominator of nearly every Chromium bot, regardless of which framework is on top. That is why CDP detection became the center of automation detection between 2023 and 2025.

The cleanest version of the technique exploits a side effect of how CDP serializes objects for the debugger. When an automation framework connects, it typically issues a Runtime.enable command so it can receive runtime events such as Runtime.consoleAPICalled. With that domain enabled, anything passed to console.log gets serialized by V8 so the debugger can display a preview of it. The trick is to hand console.log an object whose serialization has an observable consequence. An Error object works, because reading its stack property can be made to run code:

const probe = new Error();
Object.defineProperty(probe, 'stack', {
get() {
// reached only if something serialized this object's properties
markInstrumented();
return '';
}
});
console.log(probe);

In an ordinary browser with no debugger attached, nothing serializes that object’s stack, so the getter never runs and markInstrumented is never called. With CDP active and Runtime.enable sent, V8 walks the error’s properties to build the debugger preview, the getter fires, and the flag is set. The page now knows a CDP client is listening. The signal survived the obvious evasions. It did not care whether navigator.webdriver had been deleted or the user agent rewritten, because it was reading a fact about the runtime rather than a property a script can redefine. The research community attributes the popularization of this specific Error-serialization variant to a DataDome write-up by Antoine Vastel published on 13 June 2024, though the broader idea of detecting CDP through Runtime.enable behaviour predates it.

the Runtime.enable serialization side channel no debugger console.log(errorWithGetter) stack getter: not read flag = false CDP attached, Runtime.enable sent console.log(errorWithGetter) V8 serializes for preview stack getter fires, flag = true May 2025: two V8 commits add a getter guard value-mirror.cc: user-code getters (valid ScriptId) skipped during preview result: the getter never runs, the flag stays false, the probe goes dark a years-long detection signal quietly stopped firing *The Error-getter probe fired only when a CDP client had enabled the Runtime domain, until V8 began guarding user-defined getters during preview.*

The technique had two known weaknesses even at its peak, and they shaped how detectors weighted it. The first is false positives. The same serialization fires when a real human opens DevTools on the page, so a naive implementation would flag developers and curious users as bots. A detector that leans on this signal has to treat “DevTools is open” and “an automation client is attached” as the same observation and discount accordingly, which is part of why instrumentation signals contribute to a weighted score rather than acting as a hard block on their own. The second is that the framework side could fight back. Rebrowser documented mitigations that avoid the automatic Runtime.enable altogether, deferring it or using Page.createIsolatedWorld to obtain execution contexts without tripping the same serialization path. So even before the browser changed, the signal was contested from both directions.

Then it broke at the source. In May 2025 two V8 commits, dated 7 May and 9 May, changed how the inspector previews error objects. A new helper in src/inspector/value-mirror.cc guards property reads during error preview: if the getter is user code, identified by a valid ScriptId, V8 skips it entirely rather than invoking it. The commit message for the first is the plain “Avoid error side effects in DevTools.” The effect on the detection technique was total. The custom stack getter now never runs even with CDP attached, so the flag stays false, and a probe that had been a reliable automation tell for two years stopped producing a signal. Castle’s write-up on the change makes the wry point that the signal stopped working and almost nobody noticed, because detectors that relied on it simply saw their CDP-positive rate fall without an obvious cause. For Kasada, a vendor that updates its client bytecode constantly, this is the normal texture of the work: a probe ages out, the score reweights, new probes take its place. The lesson a careful detector draws is not “find a better serialization trick” but “never bet the verdict on a single mechanism,” because the mechanism can vanish in a point release for reasons that have nothing to do with you.

The CDP story is also why the Chrome team’s own changes matter to a detector. The unification of headless and headful Chrome, where new headless (--headless=new, available since Chrome 112 in early 2023) runs the same code path as a visible browser, removed a whole generation of cheap headless tells. Old headless was a separate browser implementation with its own gaps; it became a standalone chrome-headless-shell binary and was removed from the main Chrome build in Chrome 132. The Chrome team has, in effect, been narrowing the gap between automated and human browsers, which pushes detectors away from “does this look headless” and toward “is this being driven.” CDP detection is the cleaner question precisely because the browser vendor is not trying to close it.

Framework artifacts

Below the protocol-level CDP signal sits a layer of cruder but still useful tells, the fingerprints each framework leaves in the page’s own object graph. These are easier to patch than CDP behaviour, which is exactly why mature stealth tooling patches them, and exactly why a detector treats their absence after a sloppy patch as informative too.

The oldest is navigator.webdriver. The WebDriver specification requires a controlled browser to expose this property as true, and it is the single most-checked automation flag in existence. Every stealth setup deletes or masks it, so on its own it catches only the laziest scripts. But it remains in the collection because a value that is present and false, versus genuinely undefined, versus thrown-on-access because something patched the prototype clumsily, are three distinguishable states, and the last two are themselves suspicious.

Playwright leaves its own marks. Internal bindings it injects to communicate with page context have appeared in the global object under names like __playwright__binding__ and __pwInitScripts. Selenium’s ChromeDriver injects a set of variables into window and document carrying a cdc_ prefix, names of the form cdc_adoQpoasnfa76pfcZLmcfl_Array and cdc_...Window, which come straight from ChromeDriver’s source. Puppeteer’s stealth plugins have their own observable footprint, both in what they add and in the telltale shape of the patches they apply. None of these is hard to hide individually. The value to a detector is breadth: there are dozens of such artifacts across frameworks and versions, the list changes with each release, and a stealth setup has to suppress every one of them on every page and every iframe, while the detector only has to find one. The economics favour the detector here, which is the same shape you see in Imperva’s reese84 sensor and Akamai’s sensor_data payload, where the collected surface is deliberately wide so that no single patch closes it.

artifacts a framework leaves in the page all frameworks navigator.webdriver CDP serialization side effect Playwright __playwright__binding__ __pwInitScripts Selenium cdc_…_Array cdc_…_Window the asymmetry that matters stealth must suppress every artifact on every frame, every release the detector needs to find exactly one *A partial map of framework footprints. Each is individually patchable; the defender's advantage is that there are many and they change every release.*

Patched runtimes that lie

The interesting adversary is not a default Playwright install. It is a runtime that has been told to lie about itself: anti-detect browsers like the Hidemium and Multilogin families, stealth plugins, and the source-patched Chromium builds that compile their fingerprint changes into the binary instead of injecting them from JavaScript. These are built specifically to defeat the framework-artifact layer above. A source-patched browser can make navigator.webdriver genuinely undefined at the C++ level, leaving no JavaScript patch to detect. It can ship a canvas and WebGL profile that matches a real device. At that point the property-by-property checks are exhausted, and the detector has to change the question again.

The change is from what does each property say to do the properties agree with each other. A real browser is a tightly coupled system. A given Chrome version on a given OS exposes a specific, knowable set of Web APIs, a specific user-agent string, specific client hints, a specific set of supported codecs, a specific font list for that platform. Spoofing tools change some of these and forget, or cannot afford, to change all of them consistently. The detector builds challenges out of the relationships rather than the values. Research on the Hidemium family showed the pattern clearly: to make a spoofed profile self-consistent in one direction, the tool deletes APIs that the claimed browser version should expose. On Chrome 134-plus profiles, observed builds removed navigator.share, navigator.canShare, and window-level interfaces such as BarcodeDetector and CSPViolationReportBody. So the detector inverts the logic. If the user agent claims Chrome 134 on macOS, then BarcodeDetector and a handful of version-gated interfaces must be present. Their absence is not neutral; it is a contradiction, and a contradiction is a stronger signal than any single property, because a human’s browser is never internally inconsistent in this way.

The same shape repeats across every coupled subsystem. A browser’s user agent implies a codec list, and a spoofed agent that claims a Chrome build whose canPlayType answers do not match is contradicting itself. A claimed platform implies a font set, and a Linux runtime wearing a Windows user agent will measure fonts that Windows ships and Linux does not, or vice versa. WebGL vendor and renderer strings imply a plausible GPU for the claimed OS, and a software renderer behind a user agent that claims a discrete card is another mismatch. None of these is a single property the way navigator.webdriver is. Each is a relationship between two properties that a real browser keeps in agreement for free, because the browser is one program reporting on itself, and that a spoofing tool has to keep in agreement by hand across an exploding combinatorial space. The deeper the runtime patch, the more of these relationships it can satisfy, but a source patch that fixes the codec list still has to fix the font list, the WebGL strings, the API surface, the client hints, and the next version’s changes to all of them. The work does not converge.

This is the layer where Kasada’s VM design earns its keep. Consistency checks are only useful if the adversary cannot see which relationships you test. If the probes shipped as readable JavaScript, a stealth author would diff the script, enumerate every consistency check, and pre-satisfy all of them. Inside an obfuscated bytecode VM the checks are opaque and they rotate, so the stealth author is patching against a moving target they cannot fully read. The same logic protects Kasada’s KPSDK token machinery and shows up across the industry wherever a vendor wraps its collection in a VM rather than shipping it in the clear.

consistency beats single-property spoofing userAgent: Chrome 134 platform: macOS implies a known API set 'BarcodeDetector' in window → true spoofed build: deleted → false claim and capability disagree → contradiction why the VM matters here readable probes → stealth author enumerates and pre-satisfies every check opaque, rotating probes → patching a target you cannot fully read *The detector tests whether the claimed browser identity agrees with the runtime's actual capabilities. A self-consistent lie is far more expensive to produce than a single spoofed property.*

There is a behavioural envelope on top of all this. Kasada has written publicly, going back to a 2021 post on the misuse of open-source DevTools and anti-detect browsers, that it watches for automation frameworks and the antidetect ecosystem as a category, not just specific tools. A session that behaves with machine regularity, scrolling identical distances, clicking the same element on a fixed cadence, completing a form in milliseconds, navigating faster than rendering allows, accretes negative weight even if every static signal checks out. But behaviour is the slow, noisy layer. The anti-instrumentation checks are the fast, cheap ones, and they are what kill a clean-looking Playwright session before it ever gets to demonstrate good behaviour.

What this costs the runner, and where it sits now

The practical upshot for anyone on the receiving end is an unpleasant asymmetry. The defender collects a wide surface inside an opaque VM and only needs one solid contradiction. The runner has to make every signal agree, on every frame, across every browser release, while not being able to read which signals are tested or how they are weighted. That is why the durable answers in this space are not stealth patches at all but real browsers driven without an attached debugger, the CDP-free automation approaches that have grown specifically because the protocol-level signal proved so hard to spoof. When the cheapest reliable tell is “is there a CDP client attached,” the natural response is to remove the CDP client, which is a much larger engineering undertaking than deleting a property.

It is also a moving target on both sides, and not only because Kasada rotates its bytecode. The browser vendors move too, and sometimes in the runner’s favour. The May 2025 V8 getter guard that killed the Error-serialization probe was not a bot-detection decision; it was a correctness fix for DevTools that happened to remove a detector’s favourite signal. The headless unification that erased a generation of cheap tells was a maintenance decision by the Chrome team. Detectors live downstream of choices made for entirely unrelated reasons in Chromium, and a single V8 commit can retire a technique that took years to mature. Kasada’s bet, and the reason the VM is the architecture rather than a readable sensor, is that no single commit can retire consistency as a class of signal. You can patch one property. Making a driven runtime agree with itself about what it is, completely and on a target you cannot see, is the part that stays expensive.


Sources & further reading

Further reading