← All posts
How anti-scraping works · Part 3 of 10

Headless tells: how automated Chrome gives itself away

How sites detect headless Chrome, Puppeteer and Playwright in 2026: navigator.webdriver, Runtime.enable, isolated worlds and environment tells.

By the ScrapePass team···9 min read·Sources checked October 20, 2026
headlesspuppeteerplaywrightdetection

Headless browser detection is how a website tells an automated browser, such as Chrome driven by Puppeteer or Playwright, from a person's browser. Since Chrome's new headless mode the browser itself looks normal, so detection now targets the automation driver and the server environment instead.

TL;DR

  • New headless Chrome is the same binary as headed Chrome. It became the default meaning of --headless in Chrome 128 and the old mode was removed from the Chrome binary in Chrome 132 (January 2025). The old mode survives only as the separate chrome-headless-shell binary, which still announces HeadlessChrome in its user agent, and some drivers still download it by default.
  • What remains detectable is the driving, not the headlessness: navigator.webdriver, side effects of the DevTools Protocol, and the software GPU in a server environment.
  • The best known protocol tell is Runtime.enable: enabling it makes the page's console serialization observable, which is why modern stealth tools avoid that command entirely. A newer one is isolated-world detection: init scripts injected through CDP run in a separate world, and a page can tell.
  • A May 2026 benchmark of seven tools against 31 live targets found that the driver choice mattered more than any patch: a plain CDP driver on system Chrome had zero hard blocks; patched Playwright forks still failed the hardest gates.
  • Patching properties from JavaScript leaves its own traces (modified toString, wrong property descriptors). Patching at the C++ level does not.
  • A real user's browser with a real profile scores differently from any headless setup, which is why browser-extension scrapers occupy a distinct category.

Where the tells live

flowchart TB
    accTitle: Where headless browser tells come from
    accDescr: The automation driver and the server environment both leak signals into page JavaScript, which the vendor script reads.
    D[Automation driver<br/>Puppeteer, Playwright, nodriver] -->|CDP commands| CH[Chrome process]
    CH --> PG[Page JavaScript]
    D -. "Runtime.enable → console serialization leak" .-> PG
    D -. "init scripts in isolated world" .-> PG
    D -. "--enable-automation → navigator.webdriver" .-> PG
    ENV[Environment<br/>no GPU, server fonts, UTC, datacenter IP] --> CH
    ENV -. "SwiftShader renderer, font list, timezone" .-> PG
    PG --> V[vendor script reads all of it]
    class D violet
    class ENV amber
    class CH,PG soft
    class V bad
    linkStyle 2,3,4,6 stroke:#ef4444,stroke-width:2px

Solid arrows are the normal path. Dotted arrows are side effects a page can observe. The first three come from the driver and can be fixed in tooling. The last comes from the machine and cannot.

What changed with new headless

For years, headless Chrome was a separate implementation with gaps: no chrome object, an empty plugin list, a user agent containing "HeadlessChrome", and outerWidth of zero. Detection scripts checked for these, and stealth plugins patched them one by one.

Chrome 112 shipped a new headless mode that runs the full browser without a window. Chrome 128 made it what --headless means, and Chrome 132 removed the old implementation from the binary; --headless=old now prints an error. The old mode lives on as chrome-headless-shell, a separate download that Puppeteer and some CI images still use for speed. If your automation reports HeadlessChrome in the user agent in 2026, you are running the shell, not Chrome.

With the real binary the gaps closed. A new headless Chrome with a GPU and a normal profile passes the simple property checks without any plugin. The detection surface moved to three places.

Tell 1: automation flags

When Chrome is launched by a WebDriver client or with --enable-automation, navigator.webdriver is true. This is the specified behavior and it is the first thing every detector reads. Puppeteer and Playwright set the flag by default; both can be told not to, and tools such as nodriver and undetected-chromedriver never set it.

Related: the --headless switch still changes a few things, such as the default window size and the absence of a real display. Launch with a realistic --window-size and, on Linux servers, under a virtual display or with a GPU, or the geometry and renderer will not match the claimed platform.

Tell 2: DevTools Protocol side effects

Puppeteer, Playwright and Selenium's CDP mode drive the page through the Chrome DevTools Protocol. Some CDP commands have effects a page can observe. The best known is Runtime.enable, which the clients send to receive console and exception events.

Once enabled, Chrome serializes objects passed to console.log for the inspector. A page can log an object with a getter and check whether the getter ran. If it did, something is listening, and that something is DevTools.

Error stack traces are another case: with an inspector attached, certain stack formats and Error.prepareStackTrace behaviors differ. Detectors measure these.

The response from tooling was to stop calling Runtime.enable. nodriver never did; Patchright (a Playwright fork, still actively released, version 1.63 in September 2026) and the older rebrowser-patches project removed it, at the cost of some automation features that depended on it. rebrowser-patches has had no code commits since September 2024 and in the May 2026 benchmark its block set was identical to unpatched Playwright, so treat it as historical.

The next protocol tell is isolated worlds. Playwright and Puppeteer run init scripts and some evaluations in an isolated JavaScript world so that page code cannot see them. A page can detect that: it defines a Symbol-keyed property on window and checks, from a context it controls, whether code that should have seen it did.

There is no clean fix in stock Playwright as of 2026, which is another reason drivers that stay in the main world score better. Related: there is a brief window after navigation in which a page script can read navigator.webdriver before an init script has patched it, so timing-based checks catch property patches that static checks miss.

Tell 3: the environment

A datacenter machine has no real GPU, so WebGL reports SwiftShader or a llvmpipe renderer. It has a server font set, often only DejaVu. Its timezone is UTC and its IP is in a hosting ASN.

None of these are "headless" tells strictly, but they appear together with headless browsers so often that vendors treat the cluster as one signature. Fixing the driver while leaving the environment untouched does not help.

JavaScript patches leave fingerprints

The first generation of stealth plugins overwrote properties from an injected script: redefine navigator.webdriver, add fake plugins, override WebGLRenderingContext.getParameter. Each override is detectable. Function.prototype.toString on a patched method returns JavaScript source instead of [native code] unless toString is also patched, and then the patch of toString is detectable through its own descriptor.

Prototype chains gain extra properties. Detectors enumerate these.

Second generation tools apply changes inside the browser binary or through CDP emulation commands that Chrome itself supports, so there is nothing for page JavaScript to see. Camoufox does this for Firefox at the C++ level. The stealth tooling post compares them.

The targets keep moving on their own. The structure of window.chrome changed in 2024 and again in early 2026, so a faked object that matched last year's shape is now a tell. Fake plugins that are plain objects fail instanceof Plugin. And the Chromium team itself patches the most publicized detection vectors in CDP, which means a tool that was clean in one release can regress or improve in the next without changing a line.

Demonstration: score a browser from a page

Save this as score.html, serve it locally, and open it in headed Chrome, then in Puppeteer with defaults, then in Puppeteer with --disable-blink-features=AutomationControlled and no Runtime.enable (the rebrowser-puppeteer package exposes that).

<script>
const checks = {};
checks.webdriver = navigator.webdriver === true;
checks.softwareGpu = (() => {
  const g = document.createElement('canvas').getContext('webgl');
  const d = g && g.getExtension('WEBGL_debug_renderer_info');
  const r = d ? g.getParameter(d.UNMASKED_RENDERER_WEBGL) : '';
  return /swiftshader|llvmpipe|software/i.test(r);
})();
checks.zeroOuter = outerWidth === 0 || outerHeight === 0;
checks.patchedToString = /webdriver|plugins/.test(
  Function.prototype.toString.call(Object.getOwnPropertyDescriptor(Navigator.prototype, 'webdriver').get));
checks.permissionMismatch = false;
navigator.permissions.query({ name: 'notifications' }).then(p => {
  checks.permissionMismatch = Notification.permission === 'denied' && p.state === 'prompt';
});
checks.runtimeEnable = false;
const probe = { get leak() { checks.runtimeEnable = true; return 1; } };
console.log(probe);
setTimeout(() => {
  const score = Object.values(checks).filter(Boolean).length;
  document.body.textContent = JSON.stringify({ score, ...checks }, null, 2);
}, 500);
</script>

Expected results:

Browser score which checks
Headed Chrome, your profile 0 none
Puppeteer defaults on a laptop 2 webdriver, runtimeEnable
Puppeteer defaults on a Linux server 3 to 4 plus softwareGpu, sometimes zeroOuter
rebrowser-puppeteer with automation flag off 0 to 1 softwareGpu on a server only

The runtimeEnable check is the interesting row. It is invisible to property inspection and survives every JavaScript stealth patch. Only not sending the command fixes it.

Read more: the full list of classic tells and their status
Tell Old headless New headless Notes
"HeadlessChrome" in user agent yes (chrome-headless-shell) no old mode removed from Chrome in 132
navigator.webdriver with automation with automation flag controlled, not headless controlled
Missing window.chrome yes no
Empty navigator.plugins yes no Chrome now reports a fixed PDF plugin set everywhere
outerWidth === 0 yes depends set a window size
Permissions and Notification mismatch yes mostly fixed
Software WebGL renderer environment environment GPU or a realistic emulated string
Missing Accept-Language yes no set it anyway
Runtime.enable console serialization driver driver avoid the command
CDP Page.addScriptToEvaluateOnNewDocument artifacts driver driver injected scripts appear in stack traces if careless
Mouse events with isTrusted: false script script use CDP input, not DOM dispatchEvent
Isolated-world init scripts (Symbol probe) driver driver no clean fix in stock Playwright, 2026
window.chrome shape drift fake fake shape changed 2024 and early 2026
UA without matching Client Hints config config stopped passing in 2024 to 2025
Read more: why a user's own browser is a different category

A browser extension that scrapes inside the user's logged-in Chrome has a real GPU, real fonts, a real profile history, real cookies, the user's own connection, and no DevTools session. To the layers discussed here it looks like a person's browser, because it is one. The tradeoffs are different: throughput is limited by one machine and one IP, the user is accountable for the account used, and the extension's own content script must avoid changing the page in ways a vendor script could notice. That category is where in-browser tools such as ScrapePass sit, and it is why they don't use the stealth machinery above. Sites can still restrict access or limit requests from a real browser, so rate limits, terms and permissions apply as usual.

Frequently asked questions

Can websites detect headless Chrome?

They can detect most automated sessions, though rarely from headless mode alone. New headless Chrome is the same binary as normal Chrome. What gives it away is the driver, through navigator.webdriver, DevTools Protocol side effects and injected scripts, and a server environment with no GPU and a datacenter IP.

What is the Runtime.enable leak?

Automation libraries send the DevTools command Runtime.enable to receive console events. Once it is on, Chrome serializes objects passed to console.log, and a page can notice because a getter on the logged object runs. Drivers such as nodriver and Patchright avoid the command to remove this tell.

Does puppeteer-extra-plugin-stealth still work?

Not against modern detection. It patches properties from JavaScript, and those patches are detectable through function toString results, property descriptors and timing. Its maintainers deprecated it in February 2025.

Is HeadlessChrome still in the user agent?

Only in chrome-headless-shell, the separate binary that keeps the old headless mode. Chrome removed the old mode from the main binary in version 132, and new headless Chrome reports a normal user agent.

Sources, checked October 2026

How anti-scraping works

Ten posts on what bot detection measures, how the vendors use it, and what current tools do about it. Each one starts with a TL;DR and ends with an experiment you can run.

  1. Anatomy of a browser fingerprint
  2. TLS and HTTP/2 fingerprints: identified before the first header
  3. Headless tells: how automated Chrome gives itself away
  4. Behavioral biometrics: scoring humans without a CAPTCHA
  5. WebRTC, DNS and proxy leaks: how your real IP escapes
  6. Inside an anti-bot client script: collect, challenge, token
  7. IP reputation and rate models: why slowing down is not enough
  8. The stealth tooling landscape in 2026, with a benchmark you can run
  9. Intercept the internal API instead of scraping the HTML
  10. CAPTCHAs in 2026: the challenge is the fallback, not the gate