How to Evade Headless Browser Detection in 2026
Headless browser detection flags navigator.webdriver, CDP leaks, and SwiftShader WebGL. Learn how sites spot headless Chrome and how to test and evade it.

Headless browser detection is how a website tells apart a human clicking through Chrome from a script driving Chrome with no visible window. Get it wrong and your automation collects CAPTCHAs and 403s instead of data. This guide breaks down the exact signals sites check, from navigator.webdriver to Chrome DevTools Protocol leaks and the SwiftShader WebGL tell, hands you a snippet to test your own setup, and shows where a managed API takes the arms race off your plate.
Scrape responsibly before you evade detection
Evading detection is a technical skill, not a license. Before you touch any of the tricks below, set the ground rules.
Collect public data only. Pages behind a login, paywall, or an explicit "no automated access" agreement are off limits unless you have permission. Read the site's robots.txt and Terms of Service, and treat them as the boundary of what you crawl. Rate-limit yourself: a few requests per second from one origin is polite, hammering an endpoint hundreds of times a second is abuse that can degrade the service for real users. Identify yourself where a site offers an official API or a contact channel, and prefer that route when it exists.
Two things worth saying plainly. First, none of these techniques is a guarantee. Detection stacks update weekly, and a setup that sails through today can trip a new heuristic tomorrow. Second, legitimate reasons to defeat headless detection are everywhere: QA teams running end-to-end tests, security researchers auditing their own properties, price and availability monitoring on public catalogs, and compliance checks. Use the knowledge for those. If you are unsure whether a target permits automated access, the safe answer is to ask.
For the fingerprinting layers that sit next to headless detection, see our primers on browser fingerprinting and TLS fingerprinting.
What headless browser detection actually looks for
Detection is not one check. It is a scoring system that pulls signals from three layers and looks for contradictions between them.
| Layer | What it inspects | Headless tell |
|---|---|---|
| Network | TLS handshake (JA3/JA4), HTTP/2 frame order, IP reputation | Library TLS fingerprint that does not match the claimed Chrome; datacenter IP |
| Browser | JS properties, WebGL, CDP behavior, driver leaks | `navigator.webdriver`, missing plugins, SwiftShader renderer, CDP artifacts |
| Behavioral | Mouse paths, timing, scroll, dwell time | No movement, instant form fills, machine-perfect timing |
The single most important idea in this whole guide: modern anti-bot systems score consistency, not any one flag. A request that claims to be Chrome 138 on Windows but renders WebGL through SwiftShader and has an empty plugins list is not "a browser with three small quirks." It is a contradiction, and contradictions are what push a session over the block threshold. Spoofing one value while leaving the others inconsistent often makes you more detectable, not less. Keep that in mind for every fix below.
This article focuses on the browser layer, where headless Chrome betrays itself. The network layer is covered in the TLS piece linked above, and behavior is a topic of its own.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
The HeadlessChrome tell: old headless vs new headless
For years the easiest bot check in existence was the User-Agent string. Old headless Chrome advertised itself literally:
// Old headless mode
navigator.userAgent;
// "Mozilla/5.0 ... HeadlessChrome/114.0.0.0 Safari/537.36"
The token HeadlessChrome instead of Chrome was a free flag, and sites still test for it:
if (/Headless/i.test(navigator.userAgent)) {
flagAsBot();
}
Chrome 109 (January 2023) introduced a rewritten headless mode, invoked as --headless=new. It is the real browser running without a window rather than a stripped-down shell, so it reports a normal Chrome/ User-Agent and closes several of the old gaps at once. Since Chrome 132 (early 2025) the bare --headless flag maps to this new mode by default, and --headless=old is the way to opt back into the legacy shell.
The practical takeaway: run new headless, always. If your stack, your driver, or a pinned older Chrome still launches old headless, you are leaking the HeadlessChrome token before any clever evasion runs. Upgrading is necessary. It is nowhere near sufficient, because new headless still fails the GPU and CDP checks that follow.
window.chrome, permissions, plugins, and language tells
A cluster of small JavaScript properties gives away automated Chrome when they contradict each other. None is decisive alone. Together they build a profile.
window.chrome. Real desktop Chrome exposes a window.chrome object with runtime, loadTimes, and csi members. Old headless left it missing or nearly empty. New headless populates the base object, but deep members like chrome.runtime can still differ from a genuine extension-capable browser.
const missingChrome = !window.chrome || !window.chrome.runtime;
The permissions mismatch. This is a classic and it still works. Headless Chrome returns Notification.permission === 'denied' while the Permissions API reports the state as prompt for the same capability. A real browser keeps those two in agreement.
const perm = await navigator.permissions.query({ name: 'notifications' });
const mismatch = Notification.permission === 'denied' && perm.state === 'prompt';
// mismatch === true is a strong headless signal
Plugins. Headless historically returned an empty navigator.plugins. Modern Chrome ships a fixed compatibility list of five PDF-viewer entries, so a length of zero now stands out rather than blending in.
const suspiciousPlugins = navigator.plugins.length === 0;
Languages. Old headless returned an empty navigator.languages and no Accept-Language. A real browser reports something like ["en-US", "en"]. Set the locale explicitly on your context so this never reads empty.
Fixing these means making values consistent with the platform you claim to be, not stuffing in fake data. If your User-Agent says macOS, your navigator.platform, fonts, and WebGL vendor should agree.
WebGL vendor and the SwiftShader fingerprint
This is the tell that catches server-side scrapers even after they patch every JavaScript property. Headless Chrome on a machine without a GPU, which describes almost every cloud server, cannot do hardware rendering. It falls back to software rendering through SwiftShader, and that shows up in the WebGL metadata.
const gl = document.createElement('canvas').getContext('webgl');
const dbg = gl.getExtension('WEBGL_debug_renderer_info');
const renderer = gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL);
// Real machine: "ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 ...)"
// Headless server: "ANGLE (Google, Vulkan 1.3 (SwiftShader Device ...))"
Any renderer string containing SwiftShader or llvmpipe screams "no real GPU," which on a client browser means a virtual machine or a headless server. A detector that sees a Windows Chrome User-Agent paired with a SwiftShader renderer has found its contradiction.
Two ways out. Run the browser on a host with a real GPU and let Chrome use it, which is why running headed on a workstation is quieter than headless on a bare server. Or spoof the vendor and renderer strings to a plausible pairing, which is what stealth plugins attempt. Spoofing is fragile because the value has to line up with the rest of the profile: claiming an NVIDIA renderer on a User-Agent that says macOS is a fresh contradiction. This is one of the hardest tells to fake convincingly, and it is a big reason a managed rendering fleet with real GPUs beats a DIY server.
CDP leaks: how Runtime.enable exposes you
Everything above lives in JavaScript. This one lives in the protocol. Puppeteer, Playwright, and modern Selenium all drive Chrome through the Chrome DevTools Protocol (CDP), and the way they use it leaves a trace that page JavaScript can observe.
The most discussed example is the Runtime.enable leak. When an automation client enables the CDP Runtime domain, Chrome starts generating rich "object previews" for values passed to the console, and building those previews reads the object's property getters. A page can plant a bait object with a getter and watch for it to fire:
let cdpAttached = false;
const bait = {};
Object.defineProperty(bait, 'id', {
get() {
cdpAttached = true; // fires only while the Runtime domain is enabled
return '';
},
});
console.debug(bait); // Chrome builds a preview and reads bait.id
setTimeout(() => { if (cdpAttached) flagAsBot(); }, 0);
With no CDP client attached, nothing consumes the console message and the getter never runs. With Puppeteer or Playwright attached in their default configuration, the preview machinery trips the getter and the page knows a debugger is present. This is exactly the leak the JavaScript-only stealth plugins do not touch, which is why a browser can pass the navigator.webdriver check and still get caught here.
The fix is at the protocol layer. The rebrowser-patches project (2024) reworks how Puppeteer and Playwright use CDP so Runtime.enable is not called globally, and both frameworks have shipped their own mitigations. The exact behavior depends on your Chrome version, so test it, do not assume it. The lesson generalizes: JavaScript spoofing and CDP hygiene are two separate jobs.
Framework object leaks: Playwright, Puppeteer, Selenium
Beyond the protocol, some drivers scatter named artifacts into the page. Selenium's ChromeDriver is the worst offender. It injects properties onto document whose names start with cdc_ or $cdc_, plus older markers like __webdriver_evaluate and __selenium_evaluate. A detector enumerates them directly:
const docProps = Object.getOwnPropertyNames(document);
const seleniumLeak = docProps.some(
(k) => k.startsWith('cdc_') || k.startsWith('$cdc_')
);
const legacyLeak = ['__webdriver_evaluate', '__selenium_evaluate', '__driver_evaluate']
.some((k) => k in document || k in window);
Puppeteer and Playwright are cleaner here. They do not inject the cdc_ variables, so this particular scan comes back empty. They give themselves away through navigator.webdriver, the CDP behavior above, and the consistency mismatches, rather than through named globals. That is why undetected-chromedriver exists specifically for the Selenium world: its main job is to patch out the cdc_ variables and the automation switches that ChromeDriver adds. For Puppeteer and Playwright, the work shifts toward the CDP and property layers.
A detection test you can run right now
Before you fix anything, measure what you leak. Run this inside your automated page (page.evaluate in Playwright or Puppeteer) and read the report. Every field that looks wrong is a signal a real site can read too.
async function headlessReport() {
const gl = document.createElement('canvas').getContext('webgl');
const dbg = gl && gl.getExtension('WEBGL_debug_renderer_info');
const perm = await navigator.permissions.query({ name: 'notifications' });
return {
webdriver: navigator.webdriver, // want: false
headlessToken: /Headless/i.test(navigator.userAgent), // want: false
windowChrome: !!window.chrome, // want: true
plugins: navigator.plugins.length, // want: > 0
languages: navigator.languages, // want: non-empty
permissionMismatch:
Notification.permission === 'denied' && perm.state === 'prompt', // want: false
webglRenderer: dbg
? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) // want: no "SwiftShader"
: null,
};
}
// Playwright:
// const leaks = await page.evaluate(headlessReport);
// console.log(leaks);
Run it once with a plain --headless=new launch, then again with your stealth setup, and compare. To test the CDP leak specifically, drop the bait snippet from the CDP section into the same page. If cdpAttached flips to true, your protocol layer is still exposed no matter how clean the JavaScript report looks. Public fingerprint pages are useful for a second opinion, but running the checks yourself tells you exactly which layer to fix first.
Stealth toolkits and why they are not enough
Open-source stealth libraries automate the JavaScript patches. They are a real help and a real trap: because their signatures are public, anti-bot vendors test for the exact patches they apply.
| Tool | Target | Fixes | Misses |
|---|---|---|---|
| puppeteer-extra-plugin-stealth | Puppeteer (Node) | webdriver, window.chrome, plugins, WebGL vendor, permissions | CDP Runtime.enable leak, TLS/HTTP2 fingerprint, IP reputation |
| playwright-stealth (and forks) | Playwright (Python) | Similar JS property evasions | Same protocol and network gaps |
| undetected-chromedriver | Selenium (Python) | cdc_ variables, automation switches, real Chrome | Widely fingerprinted, fragile across Chrome versions |
| nodriver | CDP-direct (Python) | Avoids chromedriver entirely, async | Newer, smaller ecosystem |
| rebrowser-patches | Puppeteer / Playwright | The CDP Runtime.enable leak | JS property spoofing (pair with a stealth plugin) |
Here is what none of them fix on their own. They do not correct your TLS and HTTP/2 fingerprint, so a stealthed browser sending a request through a mismatched HTTP client still fails JA3/JA4 analysis. They do not repair IP reputation, so a perfectly disguised browser coming from a flagged datacenter subnet is still suspect. And a plain stealth plugin does not close the CDP leak, which is why you pair it with rebrowser-patches rather than trusting it alone.
A working setup usually stacks several pieces: new headless, a stealth plugin for the property layer, a CDP patch for the protocol layer, clean residential IPs for the network layer, and human-like timing for behavior. Maintaining that stack across weekly Chrome releases and anti-bot updates is the actual cost, and it is ongoing. Puppeteer usage patterns are covered in our Puppeteer proxy guide, and rendering-heavy targets in scraping dynamic JavaScript sites.
How a managed Scraping API sidesteps headless detection
At some point the arithmetic changes. If you are patching Chrome flags, chasing the next CDP leak, rotating residential IPs, and re-testing after every Chrome release, you are running an anti-detection product as a side project. A managed rendering API absorbs that work.
The SparkProxy Scraping API renders your target with a maintained, patched headless fleet and returns the HTML. You send a URL, it handles the browser hardening, proxy rotation, and retries. The two parameters that matter most for detection are render_js=true, which runs the page in a real rendering context, and stealth=true, which layers on additional evasions. Add premium_proxy=true to exit through residential IPs and country_code to pick a region.
import requests
resp = requests.get(
"https://scrape.sparkproxy.io/api/v1",
headers={"X-API-Key": "YOUR_API_KEY"},
params={
"url": "https://www.sparkproxy.io/",
"render_js": "true",
"stealth": "true",
"premium_proxy": "true",
"country_code": "US",
},
timeout=90,
)
print(resp.status_code, len(resp.text))
The same call from the shell:
curl -G "https://scrape.sparkproxy.io/api/v1" \
-H "X-API-Key: YOUR_API_KEY" \
--data-urlencode "url=https://www.sparkproxy.io/" \
--data-urlencode "render_js=true" \
--data-urlencode "stealth=true" \
--data-urlencode "premium_proxy=true"
What you offload: the headless GPU problem (the fleet renders with real graphics, so no SwiftShader tell), the CDP hygiene, the WebGL and property consistency, and the IP layer that stealth plugins never touch. What you own: sending polite, well-targeted requests to pages you are allowed to scrape. For self-managed stacks you still run yourself, the Playwright with proxies guide covers the network half.
This is not magic and it is not a promise of a 100 percent pass rate against every target. It is a maintained baseline that moves with Chrome and the detection vendors, so your code does not have to.
Frequently asked questions
FAQ
They score signals across three layers: the network (TLS and HTTP/2 fingerprints, IP reputation), the browser (navigator.webdriver, WebGL renderer, CDP behavior, driver artifacts), and behavior (mouse, timing, scroll). Contradictions between layers, such as a Chrome User-Agent paired with a SwiftShader renderer, are what trigger a block.
Not always. It reads true by default under Puppeteer, Playwright, and Selenium, but launching Chromium with --disable-blink-features=AutomationControlled sets it back to false. That only clears one JavaScript flag, so CDP leaks and WebGL tells still remain.
No. New headless removes the easy tells, the HeadlessChrome User-Agent token and the missing window.chrome object, but it does not fix the CDP Runtime.enable leak or the SwiftShader WebGL fingerprint on a GPU-less server. It is necessary, not sufficient.
No. They patch JavaScript properties like navigator.webdriver, plugins, and WebGL vendor, but their signatures are public and tested for, and they do not fix TLS fingerprints, IP reputation, or the CDP protocol leak. Pair them with a CDP patch such as rebrowser-patches and clean residential IPs.
When an automation client enables the DevTools Protocol Runtime domain, Chrome generates object previews for console values and reads their property getters. A page can plant a bait object with a getter and detect the automation client when the getter fires, which happens with default Puppeteer and Playwright configurations.
It depends on what you access and where you are. Scraping public data for testing, research, or monitoring is common, but bypassing access controls, ignoring Terms of Service, or collecting data behind a login can carry legal and contractual risk. Review the target's terms, respect robots.txt, and get counsel for anything sensitive.
Get 50% off your first month
Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.
Offer ends soon โ claim it before it's gone
Related articles

How to Scrape Alibaba Product Data
Scrape Alibaba product data at B2B depth: parse ladder pricing, normalize MOQ units, read supplier trust badges, and pivot rows from products to suppliers.

How to Detect When Your Scraper Is Blocked
Detect when your scraper is blocked, including silent HTTP 200 soft blocks: baselines, selector contracts, canary URLs, and per-IP success rates.

How to Bypass GeeTest CAPTCHA: Avoidance Over Solvers
Bypass GeeTest CAPTCHA by never triggering it: what moves the score, how to fix IP, TLS and session signals, and why back-off outlasts every solver.
