Kasada's anti-instrumentation: how it detects CDP, Playwright, and patched runtimes
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.
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 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.
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.
*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
- Antoine Vastel / Castle (2025), How to detect Headless Chrome bots instrumented with Playwright? — navigator.webdriver, the CDP Error-serialization side effect, and Playwright global bindings.
- Antoine Vastel / Castle (2025), Why a classic CDP bot detection signal suddenly stopped working — the May 2025 V8 commits that guard error getters during preview and break the probe.
- Antoine Vastel / DataDome (2024), How New Headless Chrome & the CDP Signal Are Impacting Bot Detection — popularized the Runtime.enable error-serialization detection.
- Rebrowser (2024), How to fix Runtime.Enable CDP detection of Puppeteer, Playwright and other automation libraries — the Runtime.enable / consoleAPICalled mechanism and isolated-world mitigations.
- Antoine Vastel / Castle (2025), Detecting Hidemium: fingerprinting inconsistencies in anti-detect browsers — how spoofed profiles delete version-gated APIs and create detectable contradictions.
- Antoine Vastel / Castle (2025), How to detect Selenium bots? — the cdc_-prefixed ChromeDriver globals and CDP artifacts.
- Chrome for Developers (2024), Chrome Headless mode — unified headless and headful code paths, chrome-headless-shell since Chrome 132.0.6793.0.
- Chrome for Developers (2024), Removing —headless=old from Chrome — old headless removed in Chrome 132, available only as a standalone binary.
- Nick Rieniets / Kasada (2021), How to stay ahead of adversaries using open-source DevTools and antidetect browsers — Kasada’s own framing of automation frameworks and the antidetect ecosystem as a threat category.
- Scrapfly (2026), How to bypass Kasada anti-scraping WAF — the staged trust-score model, x-kpsdk-ct header, and proof-of-work challenge from the runner’s side.
- lktop (2024), kpsdk: x-kpsdk-ct / x-kpsdk-cd analysis — community notes on the ips.js VM structure and the token-generation algorithm.
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 readDetecting CDP in the wild: the Runtime.enable leak and the V8 patch war
The biography of one bot-detection signal: how logging an Error object exposed a CDP-driven browser, how DataDome made the trick public in 2024, and the two V8 commits that quietly broke it in May 2025.
·26 min readDetecting automation via timing: how event latency reveals a bot
Traces how anti-bot systems read the clock instead of the cursor: event-dispatch latency, requestAnimationFrame cadence, input-to-action gaps, and why synthetic interaction keeps a suspiciously clean beat.
·18 min read