---
title: "Bot detection signals: what headless Chrome exposes"
url: https://proxy.wiki/research/bot-detection-signals-measured/
type: Research Report
author: "proxy.wiki editorial"
published: 2026-10-21
updated: 2026-10-21
site: proxy.wiki
topics: ["Anti-bot systems"]
data_status: measured
sample: "16 browser configurations x 5 runs = 80 page loads of one local page, all 80 completed: 11 configurations plus 5 single-switch isolation runs"
method: https://proxy.wiki/research/bot-detection-signals-measured/#method
key_figure: "13 of 15 checks on which Playwright's default headless launch differed from an ordinary Chrome window"
license: CC BY 4.0; quote freely with attribution to https://proxy.wiki/
---

# Bot detection signals: what headless Chrome exposes

> We loaded one local page 80 times in 16 Chrome, Playwright and Puppeteer setups and recorded what each exposed to bot detection.

## Key takeaways

- Playwright 1.62.1's default headless launch differed from an ordinary Chrome window on 13 of 15 checks, Puppeteer 23.4.0 in headless: 'shell' mode on 14, and Playwright attached to a hand-started Chrome through a fixed debugging port on 0.
- navigator.webdriver was set by --enable-automation, --remote-debugging-pipe and --remote-debugging-port=0, but not by --headless alone on Chrome 155 or by a fixed debugging port, in 5 of 5 runs each.
- Seven checks caught only the old headless shell: an unreduced version, a HeadlessChrome brand, no plugins, missing or SwiftShader WebGL, Notification.permission denied while the Permissions API said prompt, no window.chrome, and a connection.rtt of 0.
- The common fix of --disable-blink-features=AutomationControlled plus a rewritten user agent and a locale cleared 3 checks, left 10, and introduced 1: Accept-Language moved from the 14th header to the 8th.
- The console.debug Error-stack check for an attached DevTools client fired in 10 of 10 runs on Chrome 129 and in 0 of 35 on Chrome 148 and 155.

Playwright’s default headless launch, which runs chrome-headless-shell, differed from an ordinary Chrome window on 13 of the 15 checks we ran. Puppeteer in `headless: 'shell'` mode differed on 14. Playwright attached over the DevTools protocol to a Chrome we had started ourselves differed on none. The library is not what separates these results. The browser build it launches and the switches it passes are.

Those figures come from one page we wrote, served from `127.0.0.1` and loaded by 16 browser configurations five times each: 80 page loads on 21 October 2026, and all 80 completed. The page recorded what a script can read about the browser running it. The server recorded the request headers it received. No third-party site was involved, so this is a map of what a [headless browser](/glossary/headless-browser/) exposes to [bot detection](/glossary/anti-bot-system/), not a test of any vendor’s product.

This study measures what is visible, not what any site does with it. No detection vendor was tested, and nothing here shows that a given product reads a given signal or how heavily it weighs one.

## Method

A Node 24.2.0 `http` server listened on a random port on `127.0.0.1` and served a single HTML page at `/probe`. A script on that page read the properties listed below and sent them back in a `POST` to the same server. The server logged `req.rawHeaders` for the navigation request, which keeps header names in the order and case they arrived.

Every run started a fresh browser process with a fresh profile and loaded the page once. Five runs per configuration, sixteen configurations, 80 page loads. The machine was a Mac with an Apple M5 and 10 cores, running macOS 26.6.2. The control was Google Chrome 155.0.8059.85 started as a normal process with a new profile and `--no-first-run --no-default-browser-check`, no debugging switch and no automation library attached. It is the baseline every other row is compared against.

| Configuration | Driver | Browser build | Mode | Runs completed |
| --- | --- | --- | --- | --- |
| Chrome 155, headed, no automation (control) | none | Google Chrome 155.0.8059.85 | headed | 5 of 5 |
| Chrome 155 –headless, no library | none | Google Chrome 155.0.8059.85 | new headless | 5 of 5 |
| Playwright, defaults (headless shell 148) | Playwright 1.62.1 | chrome-headless-shell 148.0.7778.96 | old headless (shell) | 5 of 5 |
| Playwright, new headless (CfT 148) | Playwright 1.62.1 | Chrome for Testing 148.0.7778.96 | new headless | 5 of 5 |
| Playwright, channel 'chrome' (Chrome 155) | Playwright 1.62.1 | Google Chrome 155.0.8059.85 | new headless | 5 of 5 |
| Playwright, headless: false (CfT 148) | Playwright 1.62.1 | Chrome for Testing 148.0.7778.96 | headed | 5 of 5 |
| Playwright, defaults + common flags | Playwright 1.62.1 | chrome-headless-shell 148.0.7778.96 | old headless (shell) | 5 of 5 |
| Puppeteer, defaults (CfT 129) | Puppeteer 23.4.0 | Chrome for Testing 129.0.6668.58 | new headless | 5 of 5 |
| Puppeteer, headless: 'shell' (129) | Puppeteer 23.4.0 | chrome-headless-shell 129.0.6668.58 | old headless (shell) | 5 of 5 |
| Puppeteer, Chrome 155 as executablePath | Puppeteer 23.4.0 | Google Chrome 155.0.8059.85 | new headless | 5 of 5 |
| Chrome 155 by hand + Playwright connectOverCDP | Playwright 1.62.1 (attach) | Google Chrome 155.0.8059.85 | headed | 5 of 5 |

Five more configurations isolate single launch switches on Chrome 155 with no library attached. They appear in the `navigator.webdriver` section and in the downloadable data.

One deviation from a pure default needs stating. Playwright 1.62.1 pins Chromium build 1234 (151.0.7922.34), which was not installed on the test machine, and we did not download it. Its cache held build 1223 (148.0.7778.96) of both chrome-headless-shell and Chrome for Testing, so those binaries were passed as `executablePath`. Every other launch option stayed at its default. Puppeteer 23.4.0 ran its own pinned 129.0.6668.58 build, and once with the installed Chrome 155 so the library and the Chrome version can be told apart.

The page read these properties:

- `navigator.webdriver`, the user agent, and `navigator.userAgentData` brands plus its high-entropy values

- plugins, MIME types and languages

- the unmasked WebGL vendor and renderer

- `Notification.permission` beside `navigator.permissions.query()` for six permissions

- `window.chrome` and its keys

- window and screen geometry, focus, visibility, and the hover and pointer media queries

- media device counts, speech synthesis voices and `navigator.connection`

- a SHA-256 hash of a small 2D canvas drawing, and the list of global names on `window`

- whether `console.debug` read a getter placed on an Error’s `stack`

Those raw fields became 15 named checks. Each check asks one question, such as “is `navigator.plugins` empty?”, and a configuration counts as differing when its answer is not the control’s. Results were stable. Apart from the run number, only one of the 61 columns in the published CSV ever changed between the five runs of a configuration. That was `navigator.connection.rtt`, which Chrome rounds to a multiple of 25 ms and which read 50, 100 or 150 in the control. Every other value was identical in all five runs.

## How far each setup was from a normal Chrome window

_Of 15 page and header checks, how many differed from an ordinary Chrome window. 5 runs per configuration, 21 October 2026._

| Configuration | Checks that differed from the control |
| --- | --- |
| Chrome 155, headed, no automation (control) | 0 |
| Chrome 155 –headless, no library | 2 |
| Playwright, defaults (headless shell 148) | 13 |
| Playwright, new headless (CfT 148) | 5 |
| Playwright, channel 'chrome' (Chrome 155) | 4 |
| Playwright, headless: false (CfT 148) | 3 |
| Playwright, defaults + common flags | 11 |
| Puppeteer, defaults (CfT 129) | 5 |
| Puppeteer, headless: 'shell' (129) | 14 |
| Puppeteer, Chrome 155 as executablePath | 4 |
| Chrome 155 by hand + Playwright connectOverCDP | 0 |

Three groups fall out of the chart. The old headless shell sits at 13 or 14 with library defaults, and at 11 with the common evasion flags. Real Chrome builds driven by either library sit between 3 and 5. The two configurations that never set an automation switch, plain `--headless` and the hand-started Chrome that Playwright reached through a fixed debugging port, sit at 2 and 0.

The first group is large because chrome-headless-shell is a separate, older browser implementation. Chrome’s own documentation says that since Chrome 132.0.6793.0 the old headless mode exists only as that standalone binary. Playwright uses it by default for headless runs, and Puppeteer uses it when asked for `headless: 'shell'`. Most of what follows is about the gap between that binary and a real Chrome.

## Which checks caught what

_How many of the 10 automated or headless configurations failed each check. 5 runs each, 21 October 2026._

| Check | Configurations that differed (of 10) | Which |
| --- | --- | --- |
| navigator.webdriver is true | 7 | Playwright, defaults (headless shell 148), Playwright, new headless (CfT 148), Playwright, channel 'chrome' (Chrome 155), Playwright, headless: false (CfT 148), Puppeteer, defaults (CfT 129), Puppeteer, headless: 'shell' (129), Puppeteer, Chrome 155 as executablePath |
| User-Agent contains HeadlessChrome | 7 | Chrome 155 –headless, no library, Playwright, defaults (headless shell 148), Playwright, new headless (CfT 148), Playwright, channel 'chrome' (Chrome 155), Puppeteer, defaults (CfT 129), Puppeteer, headless: 'shell' (129), Puppeteer, Chrome 155 as executablePath |
| User-Agent carries a full, unreduced version | 3 | Playwright, defaults (headless shell 148), Playwright, defaults + common flags, Puppeteer, headless: 'shell' (129) |
| Client-hint brands include HeadlessChrome | 3 | Playwright, defaults (headless shell 148), Playwright, defaults + common flags, Puppeteer, headless: 'shell' (129) |
| No Accept-Language header | 2 | Playwright, defaults (headless shell 148), Puppeteer, headless: 'shell' (129) |
| Accept-Language sent out of the control position | 2 | Playwright, defaults + common flags, Puppeteer, defaults (CfT 129) |
| navigator.languages differs from the control | 6 | Playwright, defaults (headless shell 148), Playwright, new headless (CfT 148), Playwright, headless: false (CfT 148), Playwright, defaults + common flags, Puppeteer, defaults (CfT 129), Puppeteer, headless: 'shell' (129) |
| navigator.plugins is empty | 3 | Playwright, defaults (headless shell 148), Playwright, defaults + common flags, Puppeteer, headless: 'shell' (129) |
| WebGL missing or SwiftShader | 3 | Playwright, defaults (headless shell 148), Playwright, defaults + common flags, Puppeteer, headless: 'shell' (129) |
| Notification.permission contradicts the Permissions API | 3 | Playwright, defaults (headless shell 148), Playwright, defaults + common flags, Puppeteer, headless: 'shell' (129) |
| window.chrome is undefined | 3 | Playwright, defaults (headless shell 148), Playwright, defaults + common flags, Puppeteer, headless: 'shell' (129) |
| outerHeight minus innerHeight is 0 or negative | 6 | Playwright, defaults (headless shell 148), Playwright, new headless (CfT 148), Playwright, channel 'chrome' (Chrome 155), Playwright, defaults + common flags, Puppeteer, headless: 'shell' (129), Puppeteer, Chrome 155 as executablePath |
| screen size is not the real display | 8 | Chrome 155 –headless, no library, Playwright, defaults (headless shell 148), Playwright, new headless (CfT 148), Playwright, channel 'chrome' (Chrome 155), Playwright, headless: false (CfT 148), Playwright, defaults + common flags, Puppeteer, headless: 'shell' (129), Puppeteer, Chrome 155 as executablePath |
| navigator.connection.rtt is 0 | 3 | Playwright, defaults (headless shell 148), Playwright, defaults + common flags, Puppeteer, headless: 'shell' (129) |
| console.debug read the Error stack getter | 2 | Puppeteer, defaults (CfT 129), Puppeteer, headless: 'shell' (129) |

The broad checks were the screen size, which caught 8 of the 10, and the `HeadlessChrome` token and `navigator.webdriver`, at 7 each. Screen size and the token tell you little on their own. Real screens vary, and the token is a string anyone can replace. The narrow checks are more interesting. Seven of them caught exactly the three headless-shell configurations and nothing else: the unreduced version, the HeadlessChrome brand, empty plugins, missing or software WebGL, the permission contradiction, an undefined `window.chrome` and a zero round-trip time. Seven properties that agree with each other are harder to explain away than one.

## What sets navigator.webdriver

The W3C WebDriver specification has `navigator.webdriver` return true when the browser’s webdriver-active flag is set, meaning it is under remote control. MDN says Chrome sets it when `--enable-automation` or `--headless` is used, or when `--remote-debugging-port` is given port 0. We tested each switch alone.

| Chrome launched with | navigator.webdriver | Runs true |
| --- | --- | --- |
| --headless only | false | 0 of 5 |
| --headless --enable-automation | true | 5 of 5 |
| --headless --remote-debugging-port=0 | true | 5 of 5 |
| --headless --remote-debugging-port=9xxx | false | 0 of 5 |
| --headless --remote-debugging-pipe | true | 5 of 5 |
| chrome-headless-shell 148, URL only | true | 5 of 5 |

Our result for `--headless` alone does not match MDN. On Chrome 155 in new headless mode it left `navigator.webdriver` false in all five runs. The Chromium source file `content/child/runtime_features.cc` does list `switches::kHeadless` beside `kEnableAutomation` and `kRemoteDebuggingPipe` as triggers, so the switch is wired up in code. We did not trace why it had no effect here. The old headless shell, launched with nothing but a URL, did report true.

The same source explains the port result in a comment: port 0 is how ChromeDriver launches the browser, while a specific port number “is more likely for attaching a debugger”, so Chrome leaves the flag unset. Our fixed-port run agreed.

That maps directly onto the libraries. Puppeteer 23.4.0 launched Chrome with both `--enable-automation` and `--remote-debugging-port=0`, read back from the browser through `Browser.getBrowserCommandLine`. Playwright 1.62.1 does not pass `--enable-automation` at all. When we asked its browsers for their command line, Chrome refused with this exact message:

```
Protocol error (Browser.getBrowserCommandLine): Command line not returned because --enable-automation not set.
```

Playwright connects over `--remote-debugging-pipe` instead, and the pipe sets the flag on its own, so every Playwright launch still reported `true`. Adding `--disable-blink-features=AutomationControlled` turned it off. So did attaching Playwright with `connectOverCDP` to a Chrome we had started with `--remote-debugging-port=9xxx`: five runs, all false.

## User-Agent and client hints

| Configuration | User-Agent product token | navigator.userAgentData brands | Accept-Language header |
| --- | --- | --- | --- |
| Chrome 155, headed, no automation (control) | Chrome/155.0.0.0 | Google Chrome;v=155, Chromium;v=155, Not(A:Brand;v=24 | en-GB,en-US;q=0.9,en;q=0.8 |
| Chrome 155 –headless, no library | HeadlessChrome/155.0.0.0 | Google Chrome;v=155, Chromium;v=155, Not(A:Brand;v=24 | en-GB,en-US;q=0.9,en;q=0.8 |
| Playwright, defaults (headless shell 148) | HeadlessChrome/148.0.7778.96 | Chromium;v=148, HeadlessChrome;v=148, Not/A)Brand;v=99 | — |
| Playwright, new headless (CfT 148) | HeadlessChrome/148.0.0.0 | Not/A)Brand;v=99, Chromium;v=148 | en-GB,en;q=0.9 |
| Playwright, channel 'chrome' (Chrome 155) | HeadlessChrome/155.0.0.0 | Google Chrome;v=155, Chromium;v=155, Not(A:Brand;v=24 | en-GB,en-US;q=0.9,en;q=0.8 |
| Playwright, headless: false (CfT 148) | Chrome/148.0.0.0 | Not/A)Brand;v=99, Chromium;v=148 | en-GB,en;q=0.9 |
| Playwright, defaults + common flags | Chrome/148.0.7778.96 | Chromium;v=148, HeadlessChrome;v=148, Not/A)Brand;v=99 | en-US |
| Puppeteer, defaults (CfT 129) | HeadlessChrome/129.0.0.0 | Chromium;v=129, Not=A?Brand;v=8 | en-GB,en;q=0.9 |
| Puppeteer, headless: 'shell' (129) | HeadlessChrome/129.0.6668.58 | HeadlessChrome;v=129, Not=A?Brand;v=8, Chromium;v=129 | — |
| Puppeteer, Chrome 155 as executablePath | HeadlessChrome/155.0.0.0 | Google Chrome;v=155, Chromium;v=155, Not(A:Brand;v=24 | en-GB,en-US;q=0.9,en;q=0.8 |
| Chrome 155 by hand + Playwright connectOverCDP | Chrome/155.0.0.0 | Google Chrome;v=155, Chromium;v=155, Not(A:Brand;v=24 | en-GB,en-US;q=0.9,en;q=0.8 |

New headless mode keeps the `HeadlessChrome` token in the user agent string but does not add a `HeadlessChrome` brand to the client hints. In Chrome 155, both headless and headed reported `"Google Chrome";v="155", "Chromium";v="155", "Not(A:Brand";v="24"` in `sec-ch-ua`. The headless shell is the opposite case: it puts `HeadlessChrome` in the brand list too, and its user agent carries the full build number, `148.0.7778.96`. Chrome’s User-Agent Reduction fixed desktop Chrome at `Chrome/<major>.0.0.0`, and every real Chrome build we ran followed that format, so a full four-part version in a Chrome-claiming user agent is itself out of place.

Chrome for Testing reports only `Chromium` and a GREASE brand, with no `Google Chrome` entry. That is a property of the build, not of automation, and ordinary Chromium users will show the same thing. It still separates a Playwright or Puppeteer download from an installed Chrome.

The common fix produced a request that disagrees with itself. Rewriting the user agent left the client hints alone, so the server received a user agent that says Chrome and a brand list that says HeadlessChrome, on consecutive lines of the same request. This is the navigation exactly as captured, with only the port removed:

```
Host: 127.0.0.1:<port>
Connection: keep-alive
sec-ch-ua: "Chromium";v="148", "HeadlessChrome";v="148", "Not/A)Brand";v="99"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.7778.96 Safari/537.36
Accept-Language: en-US
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br, zstd
```

Note where `Accept-Language` sits. The control sent it last, as the 14th of 14 headers. Setting `locale` in Playwright moved it to eighth, straight after `User-Agent`. Without a locale, the headless shell sent no `Accept-Language` header at all, 13 headers in total. Puppeteer on Chrome 129 sent it sixth, but Puppeteer on Chrome 155 sent it last, so that one is a Chrome-version difference rather than a library one.

Chrome 155 sent 14 navigation headers here. Our earlier study of [default request headers](/research/default-request-headers/) counted 11 from a Chromium-based browser. The difference is the three `sec-ch-ua` client hints, which Chrome 155 sent on loopback and that capture did not contain.

## Five properties where only the headless shell differed

| Configuration | navigator.plugins | WebGL renderer | Notification.permission / permissions.query | typeof window.chrome | connection.rtt seen (ms) |
| --- | --- | --- | --- | --- | --- |
| Chrome 155, headed, no automation (control) | 5 | Apple M5 via ANGLE Metal | default / prompt | object | 50, 100, 150 |
| Chrome 155 –headless, no library | 5 | Apple M5 via ANGLE Metal | default / prompt | object | 50, 100, 150 |
| Playwright, defaults (headless shell 148) | 0 | SwiftShader (software) | denied / prompt | undefined | 0 |
| Playwright, new headless (CfT 148) | 5 | Apple M5 via ANGLE Metal | default / prompt | object | 50 |
| Playwright, channel 'chrome' (Chrome 155) | 5 | Apple M5 via ANGLE Metal | default / prompt | object | 50 |
| Playwright, headless: false (CfT 148) | 5 | Apple M5 via ANGLE Metal | default / prompt | object | 50 |
| Playwright, defaults + common flags | 0 | SwiftShader (software) | denied / prompt | undefined | 0 |
| Puppeteer, defaults (CfT 129) | 5 | Apple M5 via ANGLE Metal | default / prompt | object | 100, 150 |
| Puppeteer, headless: 'shell' (129) | 0 | no WebGL context | denied / prompt | undefined | 0 |
| Puppeteer, Chrome 155 as executablePath | 5 | Apple M5 via ANGLE Metal | default / prompt | object | 50, 100, 150 |
| Chrome 155 by hand + Playwright connectOverCDP | 5 | Apple M5 via ANGLE Metal | default / prompt | object | 50, 100 |

Every row in that table that differs from the control is a headless-shell row. The shell reported no plugins and no MIME types, and `navigator.pdfViewerEnabled` was false. Under Puppeteer it had no WebGL context at all. Under Playwright, whose launch code adds `--enable-unsafe-swiftshader`, WebGL existed but the unmasked renderer read `ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (LLVM 10.0.0) (0x0000C0DE)), SwiftShader driver)`, a software rasteriser, on a machine whose real GPU every other configuration reported as the Apple M5.

The permission result is the clearest contradiction. The WHATWG Notifications standard says that when the Permissions API state is `prompt`, `Notification.permission` must return `default`. The shell returned `denied` from one and `prompt` from the other in all 20 of its runs across four configurations, including the bare launch with no library. Every real Chrome build, headless or not, returned the matching pair.

`window.chrome` was undefined in the shell and an object with `app`, `csi` and `loadTimes` everywhere else, the control included. `chrome.runtime` was absent in every configuration, the control too, so its absence is not a tell on an ordinary page. And `navigator.connection.rtt` read exactly 0 in all 20 shell runs, where the other 60 runs read between 50 and 150.

## Window and screen geometry

_Height of the browser UI the page can infer: outerHeight minus innerHeight. Identical in all 5 runs of each configuration._

| Configuration | outerHeight minus innerHeight | outer (px) | inner (px) | screen (px) |
| --- | --- | --- | --- | --- |
| Chrome 155, headed, no automation (control) | 87 | 1200 x 824 | 1200 x 737 | 1470 x 956 |
| Chrome 155 –headless, no library | 87 | 756 x 556 | 756 x 469 | 800 x 600 |
| Playwright, defaults (headless shell 148) | 0 | 1280 x 720 | 1280 x 720 | 1280 x 720 |
| Playwright, new headless (CfT 148) | 0 | 1280 x 720 | 1280 x 720 | 1280 x 720 |
| Playwright, channel 'chrome' (Chrome 155) | 0 | 1280 x 720 | 1280 x 720 | 1280 x 720 |
| Playwright, headless: false (CfT 148) | 80 | 1282 x 800 | 1280 x 720 | 1280 x 720 |
| Playwright, defaults + common flags | 0 | 1280 x 720 | 1280 x 720 | 1280 x 720 |
| Puppeteer, defaults (CfT 129) | 224 | 1200 x 824 | 800 x 600 | 1470 x 956 |
| Puppeteer, headless: 'shell' (129) | 0 | 800 x 600 | 800 x 600 | 800 x 600 |
| Puppeteer, Chrome 155 as executablePath | -44 | 756 x 556 | 800 x 600 | 800 x 600 |
| Chrome 155 by hand + Playwright connectOverCDP | 87 | 1200 x 824 | 1200 x 737 | 1470 x 956 |
| –headless –enable-automation | 143 | 756 x 556 | 756 x 413 | 800 x 600 |

A page can infer how tall the browser’s own interface is by subtracting `innerHeight` from `outerHeight`. The control’s was 87 pixels. The headless shell reported 0 under every launcher. So did Playwright’s two headless runs on real Chrome builds, where Playwright makes the window and the viewport the same 1280 by 720 and reports a screen of that size too.

Two rows are stranger. Puppeteer driving Chrome 155 reported an outer window of 756 by 556 holding an 800 by 600 viewport, a browser frame 44 pixels shorter than the page inside it. No real window does that. And Chrome started with `--enable-automation` reported 143 pixels where plain headless reported 87. In a side check of six more page loads, not part of the data files, headed Chrome 155 lost exactly the same 56 pixels when that switch was added, 143 against 87 in three runs each. The likeliest occupant is the “Chrome is being controlled by automated test software” bar the switch turns on. We did not screenshot it.

Screens tell a similar story. The test machine’s display is 1470 by 956 and the control reported that, with a colour depth of 30. Every headless configuration except Puppeteer on Chrome 129 reported a smaller screen than that, at a colour depth of 24. Playwright’s headed run reported a 1280 by 720 screen holding an 800-pixel-tall window.

## What the common evasion flags changed

A common fix for a detected Playwright script is two lines: launch with `--disable-blink-features=AutomationControlled`, and set a user agent with `HeadlessChrome` replaced by `Chrome`. We added a `locale` as well, because the missing `Accept-Language` header is the next thing people notice. Against Playwright’s defaults on the same headless shell build, the result was this.

| Check | Playwright defaults | With the common flags | Effect |
| --- | --- | --- | --- |
| navigator.webdriver is true | differs | matches | fixed |
| User-Agent contains HeadlessChrome | differs | matches | fixed |
| User-Agent carries a full, unreduced version | differs | differs | unchanged |
| Client-hint brands include HeadlessChrome | differs | differs | unchanged |
| No Accept-Language header | differs | matches | fixed |
| Accept-Language sent out of the control position | matches | differs | introduced |
| navigator.languages differs from the control | differs | differs | unchanged |
| navigator.plugins is empty | differs | differs | unchanged |
| WebGL missing or SwiftShader | differs | differs | unchanged |
| Notification.permission contradicts the Permissions API | differs | differs | unchanged |
| window.chrome is undefined | differs | differs | unchanged |
| outerHeight minus innerHeight is 0 or negative | differs | differs | unchanged |
| screen size is not the real display | differs | differs | unchanged |
| navigator.connection.rtt is 0 | differs | differs | unchanged |

Three checks fixed, one introduced, ten left as they were. The score went from 13 to 11. Everything the flags touch lives in the user agent string and one Blink feature. Everything they leave sits in the binary: plugins, WebGL, the permission pair, `window.chrome`, the geometry and the round-trip time. A partial disguise of this kind also creates the user agent and client-hint contradiction shown above, which the undisguised default did not have.

## The DevTools console check only fired on Chrome 129

One check for an attached DevTools client logs an Error whose `stack` property is a getter. When a client has enabled the protocol’s Runtime domain, the browser builds a preview of every console argument and reads that getter, so the page can tell. We ran it first on the page, before anything else could log.

| Configuration | Browser build | Runs where the getter fired |
| --- | --- | --- |
| Puppeteer, defaults (CfT 129) | Chrome for Testing 129.0.6668.58 | 5 of 5 |
| Puppeteer, headless: 'shell' (129) | chrome-headless-shell 129.0.6668.58 | 5 of 5 |
| Puppeteer, Chrome 155 as executablePath | Google Chrome 155.0.8059.85 | 0 of 5 |
| Playwright, defaults (headless shell 148) | chrome-headless-shell 148.0.7778.96 | 0 of 5 |
| Playwright, new headless (CfT 148) | Chrome for Testing 148.0.7778.96 | 0 of 5 |
| Playwright, channel 'chrome' (Chrome 155) | Google Chrome 155.0.8059.85 | 0 of 5 |
| Playwright, headless: false (CfT 148) | Chrome for Testing 148.0.7778.96 | 0 of 5 |
| Playwright, defaults + common flags | chrome-headless-shell 148.0.7778.96 | 0 of 5 |
| Chrome 155 by hand + Playwright connectOverCDP | Google Chrome 155.0.8059.85 | 0 of 5 |
| Chrome 155, headed, no automation (control) | Google Chrome 155.0.8059.85 | 0 of 5 |

It fired in all 10 runs on Chrome 129 and in none of the 35 runs on Chrome 148 or 155 that had Playwright or Puppeteer attached. Puppeteer on Chrome 155 is the decisive row: same library, newer browser, no signal. So the check works, since it returned a positive, and it is tied to the browser version. A negative on a current Chrome does not mean no client is attached. Other ways of detecting the protocol exist, and we tested only this one.

## Two differences we did not explain

`speechSynthesis.getVoices()` returned 19 voices whenever we started Chrome 155 ourselves: the control, plain `--headless`, every single-switch run and the hand-started Chrome with Playwright attached. It returned 180 in all eight configurations that Playwright or Puppeteer launched, and in the bare headless shell with no library at all. Each count was the same in all five runs, so it is not a loading race. We did not narrow it to a cause.

The canvas hash split by renderer. Chrome 155 and Chrome for Testing 148 in new headless mode drew exactly the same pixels as the control, hash `4af6377f202aee69`. The headless shell and the Chrome 129 builds each drew something different. Global names on `window` followed the browser version. On Chrome 155 neither library added a single global name compared with the control. On the older builds every extra name was a browser interface such as `SharedStorage`, and the bare headless shell showed the same ones with no library involved.

## Reading the results

Every signal here is produced by the browser. A proxy changes the address a site sees and nothing in these tables, so the choice between [residential and datacenter addresses](/guides/residential-vs-datacenter-proxies/) cannot fix a `window.chrome` that is undefined. The same holds for the handshake one layer down, measured in our [TLS ClientHello study](/research/tls-clienthello-fingerprints/).

The binary matters more than the library. Both libraries looked alike on the same Chrome 155 build, at 4 checks each, and alike again on the headless shell, at 13 and 14. For anyone building an agent that browses, the first decision our [guide to proxies for AI agents](/guides/proxies-for-ai-agents/) describes, which fetch layer you use, is also the decision that sets most of this table.

And a block page rarely names the signal behind it. When a site refuses a request, the status code is the first evidence you have, and our guide to [403, 407 and 429 responses](/guides/403-vs-407-vs-429/) covers how to read it before guessing at [fingerprinting](/glossary/browser-fingerprinting/).

## Reproducing it

The full probe page and runner produced the downloadable data. The script below is the smallest version that shows the headless-shell result. It needs Node and Playwright and nothing else, and it only ever talks to `127.0.0.1`.

```
// mini.js: the smallest version of the probe. Node 24.2.0, Playwright 1.62.1.
// npm i playwright@1.62.1 && npx playwright install chromium
// CHROME_PATH is optional; leave it unset to use Playwright's own browser.
const http = require('http');
const { chromium } = require(process.env.PLAYWRIGHT || 'playwright');

const PAGE = `<script>
(async () => {
  const gl = document.createElement('canvas').getContext('webgl');
  const dbg = gl && gl.getExtension('WEBGL_debug_renderer_info');
  const r = {
    webdriver: navigator.webdriver,
    brands: navigator.userAgentData.brands.map(b => b.brand).join(', '),
    plugins: navigator.plugins.length,
    languages: navigator.languages.join(', '),
    webgl: dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL).slice(0, 40) : 'none',
    notification: Notification.permission,
    permissionsApi: (await navigator.permissions.query({ name: 'notifications' })).state,
    windowChrome: typeof window.chrome,
    uiHeight: outerHeight - innerHeight,
    rtt: navigator.connection.rtt,
  };
  fetch('/r', { method: 'POST', body: JSON.stringify(r) });
})();
</script>`;

let done;
const server = http.createServer((req, res) => {
  if (req.url === '/') {
    console.log('Accept-Language header:', req.headers['accept-language'] ?? '(none)');
    console.log('sec-ch-ua header:     ', req.headers['sec-ch-ua']);
    return res.end(PAGE);
  }
  let body = '';
  req.on('data', c => (body += c)).on('end', () => { res.end(); done(JSON.parse(body)); });
});

server.listen(0, '127.0.0.1', async () => {
  const browser = await chromium.launch({ executablePath: process.env.CHROME_PATH });
  const page = await browser.newPage();
  const result = new Promise(r => (done = r));
  await page.goto(`http://127.0.0.1:${server.address().port}/`);
  console.log(await result);
  await browser.close();
  server.close();
});
```

Run with `CHROME_PATH` pointing at the cached chrome-headless-shell 148.0.7778.96 build, it printed this on 21 October 2026:

```
Accept-Language header: (none)
sec-ch-ua header:      "Chromium";v="148", "HeadlessChrome";v="148", "Not/A)Brand";v="99"
{
  webdriver: true,
  brands: 'Chromium, HeadlessChrome, Not/A)Brand',
  plugins: 0,
  languages: 'en-US',
  webgl: 'ANGLE (Google, Vulkan 1.3.0 (SwiftShader',
  notification: 'denied',
  permissionsApi: 'prompt',
  windowChrome: 'undefined',
  uiHeight: 0,
  rtt: 0
}
```

To compare against a normal browser, open the same page in your everyday Chrome while the server is running and read the values from the page with DevTools closed. Your control will differ from ours in screen size, GPU and languages, and that is the point: these checks only mean something against the browser they claim to be.

## Limits

- **One machine.** Every run used the same Apple M5 Mac on macOS 26.6.2. We did not test Linux, where a server with no display or GPU is likely to give different WebGL, screen and voice results.

- **Loopback HTTP/1.1, no TLS.** The server saw plain HTTP from `127.0.0.1`. TLS and [HTTP/2 fingerprints](/glossary/http2-fingerprint/) were not measured here.

- **Versions move.** Playwright ran browser build 1223 (148.0.7778.96) instead of its pinned build 1234, and Puppeteer 23.4.0 is older than Playwright 1.62.1. Each row describes the build named in it.

- **Fresh profiles.** A real user’s Chrome carries extensions, history and settings that a new profile does not. Our control is one fresh-profile Chrome, not a sample of real users, and a difference from it is not automatically a bot signal.

- **No behaviour.** Mouse movement, typing, scrolling and timing were not measured. Each run loaded one page and read static properties.

- **No stealth plugins.** Only the libraries already installed were tested. We did not install or test any plugin that patches these properties.

- **One CDP check.** The console getter test is one of several ways to notice an attached DevTools client. A negative result covers only that test.

- **Redactions.** The time zone is published only as a flag saying whether it matched the control. It did in all 80 runs. File paths and profile directories were removed from browser command lines.

## Download the data

Every page load is published, one row per run, with the raw navigation headers in the order received. The data is licensed CC BY 4.0: reuse it with a link to this page.

- [bot-detection-signals-measured.csv](/wp-content/uploads/pw-data/bot-detection-signals-measured.csv): 80 rows, one per page load, 61 columns including the configuration, run number, every derived field and the header order.

- [bot-detection-signals-measured.json](/wp-content/uploads/pw-data/bot-detection-signals-measured.json): the same 80 runs with full page values and headers, the per-configuration summary, the 15 check definitions and the check matrix.

An empty CSV cell, or `null` in the JSON, means no value was captured for that field. It never means zero.

## Data

- [bot-detection-signals-measured.csv](https://proxy.wiki/wp-content/uploads/pw-data/bot-detection-signals-measured.csv) (CSV, 100.4 KB, 80 rows)
- [bot-detection-signals-measured.json](https://proxy.wiki/wp-content/uploads/pw-data/bot-detection-signals-measured.json) (JSON, 497.3 KB)

Licensed CC BY 4.0. Cite <https://proxy.wiki/research/bot-detection-signals-measured/>.

## Frequently asked questions

### What is bot detection?

Bot detection is the set of checks a site runs to decide whether a request comes from a person using a browser or from software. It combines what the request carries, such as headers and the TLS handshake, with what a script on the page can read about the browser, such as navigator.webdriver, plugins and WebGL. This study measures the second part, plus the headers, for headless Chrome, Playwright and Puppeteer.

### Does headless Chrome set navigator.webdriver?

Not by itself in our run. Chrome 155 with --headless alone reported false in 5 of 5 runs. Adding --enable-automation, --remote-debugging-pipe or --remote-debugging-port=0 made it true in every run. The old chrome-headless-shell reported true even when launched with nothing but a URL.

### Why does Playwright set navigator.webdriver without --enable-automation?

Playwright 1.62.1 controls Chrome over --remote-debugging-pipe, and Chromium treats that switch as automation. In our isolation runs the pipe alone set the flag on Chrome 155 in all five runs, with nothing sent over it.

### Does --disable-blink-features=AutomationControlled make Playwright look like Chrome?

No. On the headless shell it turned navigator.webdriver false and, together with a rewritten user agent and a locale, cleared 3 of the 13 checks that differed from Chrome. Ten remained, including empty plugins, software WebGL and the permission contradiction, and the Accept-Language header moved to a position the control never used.

### Is headless mode or the automation library the bigger giveaway?

The browser build. Playwright and Puppeteer each differed on 4 checks when driving the same Chrome 155, and on 13 and 14 when driving the old headless shell. The library mostly decides which build runs and which switches it gets.

### Will a residential proxy hide these signals?

No. Every signal in this study is produced by the browser on your own machine, so it is the same through any proxy, from any country and on any kind of address. A proxy changes the IP address a site sees and nothing in these tables.

### Can I reproduce the results?

Yes. The page includes a short Node script that reproduces the headless-shell result with Playwright, and every run is published as CSV and JSON under CC BY 4.0. Your screen, GPU, languages and voice counts will reflect your own machine, so compare against your own normal browser.

## Sources

1. [MDN: Navigator.webdriver property](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/webdriver)
2. [Chromium source: content/child/runtime_features.cc, the switches that enable AutomationControlled](https://chromium.googlesource.com/chromium/src/+/main/content/child/runtime_features.cc)
3. [W3C WebDriver: the navigator.webdriver attribute and the webdriver-active flag](https://w3c.github.io/webdriver/#dfn-webdriver)
4. [Chrome for Developers: Chrome Headless mode and chrome-headless-shell](https://developer.chrome.com/docs/chromium/headless)
5. [Playwright docs: Browsers, chromium-headless-shell and the new headless mode](https://playwright.dev/docs/browsers)
6. [Puppeteer docs: Headless modes](https://pptr.dev/guides/headless-modes)
7. [Chromium: User-Agent Reduction](https://www.chromium.org/updates/ua-reduction/)
8. [WICG: User-Agent Client Hints](https://wicg.github.io/ua-client-hints/)
9. [WHATWG Notifications API Standard: permissions integration](https://notifications.spec.whatwg.org/#permissions-integration)
10. [MDN: WEBGL_debug_renderer_info extension](https://developer.mozilla.org/en-US/docs/Web/API/WEBGL_debug_renderer_info)
11. [MDN: NetworkInformation.rtt](https://developer.mozilla.org/en-US/docs/Web/API/NetworkInformation/rtt)
