On this page

Headless Browser Detection: What Sites Check

headless browser detectionbrowser fingerprintinganti-bot

Headless browser detection works in layers: 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). 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

SignalCost to checkEase of spoofingFalse-positive riskWhy
navigator.webdriverLowHighLowOne flag, one patch
User agent and client hintsLowHighMediumOne header edit, but real users change them too
Missing or inconsistent APIsLowMediumMediumPlugins patch easily, cross-context mismatches don’t
CDP side effectsMediumMediumMediumAppear when a driver attaches, open DevTools can look similar
Rendering (software WebGL)MediumMediumHighReal users in VMs lack a GPU too
Network layer (TLS ClientHello, HTTP/2 SETTINGS)MediumLowMediumSet by the HTTP stack, not by page scripts
BehaviorHighLowMediumNeeds session data, and human behavior varies

navigator.webdriver is the best-known check. AlterLab’s tutorial 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 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 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 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 includes a “WebGL Rendering Test - Complex 3D rendering to detect software renderers”. Check yours with our WebGL fingerprint tool.

  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 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 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 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 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 describes JA3 as “a method for creating SSL/TLS client fingerprints”, and the FoxIO JA4 repository 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 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 agreeWhy a patch breaks it
Main-thread UA vs Worker UAThe 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.platformThe OS in the string no longer matches the platform value
Top window vs iframeA new frame may load before the patch runs
UA vs TLS and HTTP/2 fingerprintPage 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 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 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.

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 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 versionFramework and versionDateSignals that fired
Headed ChromeNone (manual)
Old headlessPuppeteer
New headlessPuppeteer
New headlessPlaywright
New headlessSelenium

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 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 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.

For browser 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.

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 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, 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. 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; 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) · Navigator: webdriver property - MDN · Headless Browser Detector - GitHub · Removing —headless=old from Chrome - Chrome for Developers · salesforce/ja3: A method for profiling SSL/TLS Clients · Passive Fingerprinting of HTTP/2 Clients · Donut Browser pricing · Donut Browser pricing. Every other source is linked where the article cites it.