Phylaxa

How headless browsers leak: catching Puppeteer and Playwright in 2026

이 글은 아직 한국어로 제공되지 않습니다. 영어 원문을 표시합니다.

Every headless-browser detection writeup starts with navigator.webdriver. Ours won't, because if that's still your primary signal in 2026, you're catching the attackers who didn't bother to open DevTools. One line in the page context removes it:

Object.defineProperty(navigator, "webdriver", { get: () => undefined });

Puppeteer's evaluateOnNewDocument runs this before any of your code. Stealth plugins have shipped it for years. Treat webdriver === true as a freebie you still check, not a detection strategy.

The interesting leaks are the ones that are expensive to patch, because they live in the browser's runtime behavior rather than in a property you can redefine. Here's where we actually catch Puppeteer and Playwright traffic in production, and why none of these signals works alone.

The leaks that survive stealth plugins

CDP residue. Both frameworks drive Chrome over the DevTools Protocol, and the protocol leaves fingerprints in the JS runtime. The classic one is Runtime.enable polluting window with objects like cdc_adoQpoasnfa76pfcZLmcfl_Array (older ChromeDriver) or, more relevant today, subtle differences in how console behaves and how errors get serialized when a CDP session is attached. The stronger modern variant: probing for timing side channels. An isolated postMessage round-trip or a Function.prototype.toString on native functions can reveal that something has been redefined by an injected script. Attackers patch these one by one; the population of unpatched sessions stays large.

Permissions and notification behavior. Ask the Permissions API about notifications and headless tells on itself:

const status = await navigator.permissions.query({ name: "notifications" });
// Headful Chrome: "prompt" (before any user gesture)
// Many headless/driver configs: "denied", out of sync with Notification.permission

The leak isn't any single value — it's the contradiction. Headful Chrome keeps Notification.permission and the Permissions API result consistent; automation stacks routinely drift them apart. Same family of bug: navigator.plugins returning a plausible-looking but wrong list, navigator.languages holding one entry on a browser whose Accept-Language header says three.

Rendering fingerprints. Headless Chrome renders. It just renders through SwiftShader or a headless compositing path unless the operator wires up a real GPU, and most don't. Reading back a canvas or a WebGL context exposes that:

const gl = document.createElement("canvas").getContext("webgl");
const ext = gl.getExtension("WEBGL_debug_renderer_info");
gl.getParameter(ext.UNMASKED_RENDERER_WEBGL);
// "Google SwiftShader" or "ANGLE (... SwiftShader ...)" = software rasterizer

SwiftShader alone isn't a verdict — plenty of real users on locked-down VMs hit software rendering. But SwiftShader plus a datacenter TLS fingerprint plus a claimed "Chrome on macOS, Apple Silicon" UA is three facts arguing with each other.

Where Puppeteer and Playwright differ

They leak differently because they're built differently, and treating "headless" as one bucket costs you recall.

Puppeteer still carries the chrome.runtime object in default new-headless mode — the automation harness needs it. Real headful Chrome on a regular web page doesn't expose a functional chrome.runtime.onConnect. Checking for its presence, and for chrome.loadTimes / chrome.csi existing, is a cheap Puppeteer tell. Puppeteer also historically exposed window.outerWidth === 0 and outerHeight === 0 in old headless; new headless fixed that, but operators pinned to old Chromium for "stability" (read: they don't want to re-test their stealth patches) still leak it.

Playwright is cleaner on the JS surface — it patches fewer visible globals — but its network layer is opinionated. Playwright's default accept headers, its sec-ch-ua behavior across versions, and the HTTP/2 SETTINGS frame of its bundled Chromium builds form a consistent fingerprint at the TLS/HTTP layer that doesn't match the same version of consumer Chrome. We've watched operators polish the JS side to a mirror finish while the JA3/JA4 and HTTP/2 fingerprint screamed "Chromium 131, but not from any Chrome release channel." UA says Chrome 136, TLS fingerprint says something that shipped in no stable Chrome: contradiction again. That inconsistency class — browser claiming one identity in JS and another at the transport layer — is one of the highest-precision signals we collect, because fixing it requires patching and rebuilding the browser's network stack, not injecting a script.

Behavior that's too clean

The most underrated leak has nothing to do with fingerprints: headless sessions are rhythmically wrong.

Real humans hover, misclick, scroll past the target and come back, tab away mid-form. A Puppeteer script doing account signup moves from page.goto to page.type to page.click with inter-action delays that are either fixed, drawn from a uniform distribution the author picked, or scaled copies of each other. Playwright's locator.click() waits for actionability and then clicks the element's center — or a point from a generator with far less variance than a real mouse. Keystroke timing from keyboard.type has inter-key intervals no human hand produces for ten thousand sessions in a row.

You can add random sleeps. Attackers do. The problem is that fake randomness has its own shape — jitter without the long pauses, the backtracks, the dead time while someone reads. Modeling the boring middle of human behavior is still the hardest part of evasion, and it's where sessions built to extract or register at volume fall apart. We wrote about the same effect in our piece on detecting LLM-powered scrapers without CAPTCHA: even agentic browsers that reason about pages still optimize for the goal, and the goal shows.

Why single signals lose, and what works instead

Here's the uncomfortable part: every signal above is individually bypassable, and most are individually noisy. Software WebGL catches VMs and old laptops. chrome.runtime catches a shrinking fraction of patched setups. Even TLS mismatches have false positives from exotic privacy browsers. If you block on any one of them, you either ban real users or teach the attacker exactly which patch to ship next.

The honest architecture is signal combination plus session-level scoring. No single observation convicts; the accumulation does. A session that is slightly off in six independent ways — rendering path, permissions coherence, transport fingerprint, input entropy, navigation rhythm, header consistency — is off in a way that costs the attacker a rebuilt browser and a rewritten behavior engine to fix. That's the economic point of detection: not a wall, but a repair bill that grows faster than the fraud margin.

This is the bet we made building Phylaxa. We collect 300+ passive signals per session and score the whole sequence with a fine-tuned LLM rather than thresholding any one of them — the same behavioral-sequencing approach we described in the scraper article, applied to the automation stacks behind most of the abusive traffic we see. Imperva's Bad Bot Report has put bad bots around a third of all web traffic for years, and headless browsers are the workhorse of that third; if you're quantifying what that costs you, our breakdown of the real cost of bot traffic has the math. Scoring runs at the edge with median latency under 5 ms, real users see zero challenges, and you can deploy it as an SDK, an edge connector, or a cloud API.

If you want to see what your traffic looks like under this lens: for a limited time Phylaxa is free for sites under 100,000 monthly users — details on the pricing page. Bring your worst endpoint. Headless sessions are more talkative than their operators think.

한시적 런칭 혜택

사용자 10만 미만 팀 무료

런칭 기간 동안 $0로 완전한 LLM 봇 탐지를 이용하세요. 엔지니어 검토 온보딩으로 1–2 영업일 내 가동됩니다.

요금 보기