---
title: "Headless Browser Detection: What Sites Check — Donut Browser"
description: "Headless browser detection checks JavaScript flags, CDP side effects, TLS, HTTP/2, and behavior. Learn to fix layer contradictions first, then test your setup."
url: "https://donutbrowser.com/pt/headless-browser-detection/"
---

1. [Início](https://donutbrowser.com/pt/)

2. [Blog](https://donutbrowser.com/pt/blog)

3. Headless Browser Detection: What Sites Check

# Headless Browser Detection: What Sites Check

October 15, 2026

headless browser detection browser fingerprinting anti-bot

**Headless browser detection [works in layers](https://alterlab.io/blog/why-headless-browser-gets-detected-how-to-fix): sites check JavaScript signals first (navigator.webdriver, user agent and client hints, missing APIs, software rendering), then CDP side effects, then network fingerprints (TLS, HTTP/2), then behavior. Cheap signals are easy to spoof. Contradictions between layers are hard to hide, so fix consistency before you patch single flags.**

Your script logs in fine in a headed browser on your laptop. Then it runs headless in CI and gets a 403 or a CAPTCHA. That is headless browser detection at work.

The focus here is why an automated job gets flagged, not how people get tracked across sites (that is [browser fingerprinting](https://donutbrowser.com/browser-fingerprinting)). By the end you can tell which layer gave you away and fix the signals in cost order.

> **Key Takeaways**

> - Patching one value, such as the user agent or `navigator.webdriver`, leaves it contradicting client hints, the Worker and TLS.

> - CDP side effects and network fingerprints sit below page JavaScript, so no `addInitScript` patch can reach them.

> - Record exact browser and framework versions and the test date, because headless behavior differs between Chrome versions.

## What does headless browser detection check, cheapest signal first?

Headless browser detection starts with cheap JavaScript flags and moves to costlier signals only when the score stays ambiguous. The table ranks seven signals by what they cost the detector. The labels are my qualitative judgment, not measured rates, and “high” under spoofing means easy to fake.

![Image generated](https://api.birchseek.com/m/EMit3KBpYo4NsWfUBuBqCPSQCVgqMs4-.webp)

| Signal | Cost to check | Ease of spoofing | False-positive risk | Why |
| --- | --- | --- | --- | --- |
| `navigator.webdriver` | Low | High | Low | One flag, one patch |
| User agent and client hints | Low | High | Medium | One header edit, but real users change them too |
| Missing or inconsistent APIs | Low | Medium | Medium | Plugins patch easily, cross-context mismatches don’t |
| CDP side effects | Medium | Medium | Medium | Appear when a driver attaches, open DevTools can look similar |
| Rendering (software WebGL) | Medium | Medium | High | Real users in VMs lack a GPU too |
| Network layer (TLS ClientHello, HTTP/2 SETTINGS) | Medium | Low | Medium | Set by the HTTP stack, not by page scripts |
| Behavior | High | Low | Medium | Needs session data, and human behavior varies |

`navigator.webdriver` is the best-known check. [AlterLab’s tutorial](https://alterlab.io/blog/why-headless-browser-gets-detected-how-to-fix) says that when Chrome runs in automation mode, `navigator.webdriver` returns true, and that every anti-bot system checks it first. A single line hides it, so it proves little on its own.

The middle rows mix page-level checks with cross-context checks. A value spoofed in the main page can then contradict the same value read from a Worker or an iframe.

The network row is the hardest to fake from inside the page.

No row decides alone. Treat PhantomJS checks as legacy, and test against current Playwright, Puppeteer and Selenium, because their artifacts differ.

Canvas, audio and font signals serve tracking more than automation detection. Our [browser fingerprinting guide](https://donutbrowser.com/browser-fingerprinting) covers them.

## Which browser signals expose headless Chrome in old and new mode?

Headless Chrome shows up in about six signals, and each depends on which mode and which framework you run. Use the list as a checklist to test against your own Chrome version, not as fixed behavior.

1. **`navigator.webdriver`.** [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/webdriver) says this read-only property “indicates whether the user agent is controlled by automation.” It’s the cheapest check and the cheapest to fake.

2. **User agent and client hints.** Strings such as `HeadlessChrome` in the user agent are a classic headless giveaway, as [Latenode’s detection guide](https://latenode.com/blog/headless-browser-detection) notes, and client hints in `navigator.userAgentData` carry the brand list too. Don’t assume new headless dropped it. Chrome’s documentation does not promise either way, so log both values on your exact version and date.

3. **Missing or inconsistent APIs.** Detectors compare `navigator.plugins`, `window.chrome`, `navigator.languages` and permission state against what a headed Chrome of the claimed version exposes. Gaps are more likely in old headless, which was a separate implementation, so run the same script in both modes.

4. **Software rendering.** The WebGL renderer string can reveal that a CPU draws the page instead of a GPU. [The headless-detector library](https://github.com/online/headless-detector) includes a “WebGL Rendering Test - Complex 3D rendering to detect software renderers”. Check yours with our [WebGL fingerprint tool](https://donutbrowser.com/tools/webgl-fingerprint).

5. **Worker context.** The same detector has a “Worker UA Check - Compares User-Agent between main thread and Worker”. A user agent patched only on the page will contradict the worker’s.

6. **Network layer.** A patched page can’t change its TLS handshake or HTTP/2 settings, so detectors check those against the claimed browser.

### What changed between old and new headless?

[Chrome’s developer blog](https://developer.chrome.com/blog/removing-headless-old-from-chrome) says old headless is gone from the main binary as of Chrome 132, with `chrome-headless-shell` as the standalone replacement. New headless runs through the regular Chrome browser code.

A CI run that launches `chrome-headless-shell` is using the old implementation, so its gaps are the ones to expect. Check which binary your pipeline actually starts.

### Which framework artifacts are documented?

- **Playwright:** the same open-source detector lists “Playwright Detection - Identifies Playwright bindings and exposed functions”. Test your Playwright version for them.

- **Puppeteer and Selenium:** both drive Chrome through WebDriver or CDP, so `navigator.webdriver` is the signal MDN ties to automation. Other artifacts, such as ChromeDriver variables, are not published in the framework docs. Verify them by running a detector against your pinned versions.

Our [bot detection test](https://donutbrowser.com/tools/bot-detection) shows which of these signals your own browser trips.

## How do sites detect CDP, TLS and HTTP/2 behind your script?

Sites detect CDP, TLS and HTTP/2 behind your script by checking side effects and network details that page JavaScript can’t rewrite. [AlterLab’s write-up](https://alterlab.io/blog/why-headless-browser-gets-detected-how-to-fix) says headless browsers “leak identity signals at every layer” and lists the TLS handshake, HTTP headers, JavaScript APIs and rendering behavior.

**CDP side effects.** CDP is the Chrome DevTools Protocol, the channel Playwright and Puppeteer use to drive Chrome. Enabling its Runtime domain makes Chrome serialize logged objects so it can send them to the client.

A page can log an object with a getter that only fires during that serialization. If the getter runs, something is listening. Open-source detectors such as [the headless-detector library](https://github.com/online/headless-detector) list “Advanced CDP Detection” among their vectors.

**TLS fingerprints.** Before any page loads, your client sends a ClientHello: its supported cipher suites, extensions and curves, in a particular order. The [Salesforce JA3 repository](https://github.com/salesforce/ja3) describes JA3 as “a method for creating SSL/TLS client fingerprints”, and the [FoxIO JA4 repository](https://github.com/FoxIO-LLC/ja4) publishes JA4 as a successor.

A detector compares that fingerprint with the user agent. If the header says Chrome on Windows but the ClientHello looks like Python’s `requests` library, the two layers disagree. Copying Chrome’s user agent string changes one header and leaves the handshake alone.

Chrome uses the same network stack in headed and headless mode, so you’d expect the same ClientHello from both. Detectors rarely separate those modes at this layer.

Clients differ in the values they put in the HTTP/2 SETTINGS frame, in window updates, and in the order of headers. A plain HTTP library sends a different pattern from Chrome.

**What’s easy and what’s hard to fake.** Page scripts can overwrite the user agent, `navigator.webdriver` and the plugin list.

Our [guide to browser fingerprinting](https://donutbrowser.com/browser-fingerprinting) covers how TLS fits with the other signals.

## Why do stealth patches and addInitScript overrides still leave traces?

A script added with `addInitScript` has to replace functions and getters on prototypes. That replacement is itself evidence, and you can check it in a console:

```
const native = Object.getOwnPropertyDescriptor(Navigator.prototype, 'webdriver');
console.log(native.get.toString());
console.log(Object.getOwnPropertyDescriptor(navigator, 'webdriver'));

Object.defineProperty(navigator, 'webdriver', { get: () => false });
console.log(Object.getOwnPropertyDescriptor(navigator, 'webdriver'));
console.log(Object.getOwnPropertyDescriptor(navigator, 'webdriver').get.toString());
```

Before the override, the getter prints `function get webdriver() { [native code] }`, and the instance lookup returns `undefined` because the property lives on `Navigator.prototype`. After it, the instance holds its own property, and the getter prints its own source, `() => false`.

A stealth script can patch `toString` to hide this, but that is one more patch to detect.

The stronger check is agreement between layers, and a patch rarely reaches every one of them:

| Pair that must agree | Why a patch breaks it |
| --- | --- |
| Main-thread UA vs Worker UA | The patch ran in the page, not inside the Worker |
| UA string vs client hints (`navigator.userAgentData`) | Only one of the two was rewritten |
| UA vs `navigator.platform` | The OS in the string no longer matches the platform value |
| Top window vs iframe | A new frame may load before the patch runs |
| UA vs TLS and HTTP/2 fingerprint | Page JavaScript can’t change the handshake the browser sends |

The last row is the one no page script can fix, because the handshake leaves the browser before page code runs.

Cross-context and network consistency is harder to fake, but it isn’t impossible if the value is set inside the engine instead of the page. The [Bot Detection Test](https://donutbrowser.com/tools/bot-detection) shows what your own setup exposes.

## How do detectors turn signals into a score and a response?

Detectors turn signals into a response by adding them into one risk score, then mapping score ranges to actions. The [dev.to guide](https://dev.to/vhub_systems_ed5641f65d59/how-sites-detect-headless-browsers-and-how-to-evade-each-signal-2026-guide-2jj0) says “Detection is probabilistic, not binary,” and “Each anomaly adds to your score.”

A typical design weights each signal. A spoofed user agent adds little, because it’s cheap to fake. A contradiction between layers, such as a Windows user agent with a Linux TLS fingerprint, adds more.

IP reputation and behavior feed the same score. A clean fingerprint on a poor IP still fails, so match IP, timezone and language using our [proxy guide for anti-detect browsers](https://donutbrowser.com/proxies-for-anti-detect-browsers).

Thresholds then decide what you see:

- **Low score, allow:** the page loads and you notice nothing.

- **Middle score, soft challenge:** a JavaScript check, an interstitial or a CAPTCHA appears, and the session may continue if it passes.

- **Higher score, rate limit:** the first requests succeed, then you typically get `429` responses or slow pages.

- **Highest score, block:** a `403` or an empty page.

Two extras sit beside the score. A **honeypot** is a link hidden with CSS that a person never clicks, so a script that follows every link flags itself.

A **verified-bot allowlist** lets known crawlers through. A header claiming to be a crawler proves nothing, so allowlists usually check where the request came from.

### What if your headless work is legitimate?

Testing, uptime monitoring and accessibility tooling get caught in the same net, because a CI browser looks like a scraper. If that’s your case, try these in order:

1. **Use an official API or feed** if the site offers one. It avoids detection entirely.

2. **Ask the site owner** for an IP allowlist or a test header they can exempt from challenges.

3. **Keep volume low:** few requests, spaced out, from a stable IP.

None of this is a way around a ban given for abuse. If a site blocked you for scraping against its rules or for fraud, the fix is to stop, not to tune your signals.

## How do you self-test headless browser detection on your own setup?

Self-testing headless browser detection means running one detection page against every browser mode and framework you use, changing one variable per run, and writing down what fired. Plan on about 30 minutes for the first full pass.

1. **Pick one test page and keep it fixed.** The [Donut Browser bot detection test](https://donutbrowser.com/tools/bot-detection/) shows whether your browser looks automated, headless or spoofed, and which signal gave it away. Switching pages mid-test makes results incomparable.

2. **Record a baseline in headed Chrome.** Open the page by hand, then note the exact version from `chrome://version` and today’s date. A clean baseline tells you what “normal” looks like on your machine.

3. **Run old and new headless Chrome.** Launch with `--headless=old` and `--headless=new` if your Chrome version still ships both. Check each mode separately, since they may report different signals.

4. **Drive the same page with Puppeteer, Playwright and Selenium.** Note each framework’s version from `package.json` or `pip freeze`. Don’t reuse one profile directory across runs: it carries state over and hides differences.

5. **Change one thing at a time.** Swap the user agent, then re-run. Add a `navigator.webdriver` override, then re-run. Change three settings at once and you can’t tell which one moved the result.

6. **Fill in the matrix.** Copy the template below and write only what you measured.

| Browser mode and version | Framework and version | Date | Signals that fired |
| --- | --- | --- | --- |
| Headed Chrome | None (manual) |  |  |
| Old headless | Puppeteer |  |  |
| New headless | Puppeteer |  |  |
| New headless | Playwright |  |  |
| New headless | Selenium |  |  |

Expect the user agent, `navigator.webdriver` and plugin checks to be the easiest to change. In my judgment, cross-context consistency, network fingerprints and behavior are much harder to fake, and a test page can only partly show them.

A pass on one test page doesn’t mean a pass on a production site. Production detectors also use the network layer, IP reputation and behavior, which a single page can’t fully reproduce.

## What can engine-level spoofing fix, and what can it not?

Engine-level spoofing fixes values that a script would otherwise paint onto the page, but it can’t fix your behavior, your IP reputation or the automation protocol.

A page-level patch changes what one JavaScript context reports. Engine-level spoofing changes the browser itself, so main thread, workers and iframes agree, and no patched prototype or `toString` trail exists. That matters because detectors compare contexts, as the Worker user agent check shows.

Donut Browser’s only engine is [Wayfern, a Chromium fork](https://donutbrowser.com/pricing) that sets user agent, platform, timezone, language, WebGL, canvas, audio and WebRTC at the C++ level. The free plan gets the same fingerprint quality as paid plans.

If you use another browser, the test is the same: check that each value holds in every context.

**What stays out of reach:**

- **Behavior.** Straight-line mouse paths and instant typing still read as a script. The [open-source (MIT) `wayfern` npm helper](https://github.com/zhom/donutbrowser) humanizes mouse, typing and scroll on top of Playwright, which helps but isn’t proof of a person.

- **IP reputation.** A datacenter IP stays a datacenter IP.

- **The automation protocol.** Attaching over CDP can leave side effects the engine doesn’t hide.

Donut doesn’t make every user look identical, doesn’t sell proxies, isn’t a VPN, and doesn’t guarantee accounts never get banned. Behavior and platform rules stay with you. For the basics, read our [anti-detect browser explainer](https://donutbrowser.com/what-is-an-anti-detect-browser/).

For [browser automation](https://donutbrowser.com/use-cases/automation/), you launch a profile with `POST /v1/profiles/{id}/run` and attach with `connectOverCDP`. That needs the Pro plan, since the free plan has no CDP endpoint. [See what the Pro plan includes](https://donutbrowser.com/pro/).

## Frequently asked questions

### What is headless browser detection?

**Headless browser detection is the set of checks a site runs to tell whether a browser is driven by a script with no visible window.** Sites compare JavaScript signals such as `navigator.webdriver`, the user agent and the WebGL renderer with network fingerprints like TLS and HTTP/2, then score the mismatches and behavior.

### Does new headless Chrome still say HeadlessChrome in the user agent?

**It depends on your Chrome version and mode, so read the value from your own build.** Log `navigator.userAgent` in headed, old headless and new headless on the exact version you run. Then compare it with the user agent inside a Worker.

### Can I hide navigator.webdriver with a script?

**Only on the page you patch, and other contexts can still give you away.** [Detectors like headless-detector](https://github.com/online/headless-detector) also compare the User-Agent between the main thread and a Worker, so a patched page can still contradict itself.

### Why does my script pass headed but fail headless?

**Headless changes more than the missing window.** Some detectors run a complex 3D WebGL test to spot software renderers. Run one page, such as our [free bot detection test](https://donutbrowser.com/tools/bot-detection), against each mode and against Playwright, Puppeteer and Selenium, changing one variable per run.

## Next steps: test, fix the cheap signals, then check the network layer

Fixing signals one at a time won’t end headless browser detection, so work in order.

**Today:** Run the same script headed and headless against the [bot detection test](https://donutbrowser.com/tools/bot-detection/). Write down the Chrome version, the framework version and the date next to each result, so you can compare runs later.

**This week:** Fix contradictions between layers before chasing single values. A user agent that disagrees with client hints or the worker context matters more than one odd `navigator.webdriver` result. Then check your IP reputation and how your script behaves, since a browser patch changes neither.

**Later:** Re-run the test after every Chrome or framework upgrade. A new release can change a signal you already fixed.

Donut Browser’s free plan needs no account, so you can start today. Attaching Playwright or Puppeteer through the local API needs the [Pro plan](https://donutbrowser.com/pricing/); the fingerprint engine itself is the same on every plan. No tool guarantees an unflagged job, so test your own setup first.

## Sources

[Headless Browser Detection: 6 Signals Sites Use (+ Fixes)](https://alterlab.io/blog/why-headless-browser-gets-detected-how-to-fix) · [Navigator: webdriver property - MDN](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/webdriver) · [Headless Browser Detector - GitHub](https://github.com/online/headless-detector) · [Removing —headless=old from Chrome - Chrome for Developers](https://developer.chrome.com/blog/removing-headless-old-from-chrome) · [salesforce/ja3: A method for profiling SSL/TLS Clients](https://github.com/salesforce/ja3) · [Passive Fingerprinting of HTTP/2 Clients](https://lwthiker.com/networks/2022/06/17/http2-fingerprinting.html) · [Donut Browser pricing](https://donutbrowser.com/docs/wayfern/) · [Donut Browser pricing](https://donutbrowser.com/pricing). Every other source is linked where the article cites it.

## On this page

- What does headless browser detection check, cheapest signal first?

- Which browser signals expose headless Chrome in old and new mode?

- What changed between old and new headless?

- Which framework artifacts are documented?

- How do sites detect CDP, TLS and HTTP/2 behind your script?

- Why do stealth patches and addInitScript overrides still leave traces?

- How do detectors turn signals into a score and a response?

- What if your headless work is legitimate?

- How do you self-test headless browser detection on your own setup?

- What can engine-level spoofing fix, and what can it not?

- Frequently asked questions

- What is headless browser detection?

- Does new headless Chrome still say HeadlessChrome in the user agent?

- Can I hide navigator.webdriver with a script?

- Why does my script pass headed but fail headless?

- Next steps: test, fix the cheap signals, then check the network layer

- Sources
