How to Beat Headless Chrome Detection in 2026

By Serpent API Team · · 13 min read

You installed the stealth plugin, you launched headless, and the site still served you a CAPTCHA. Welcome to the most frustrating part of scraping: detectors do not check one thing, they check dozens, and stealth only covers some of them.

The truth nobody puts on the tin is that "undetectable" headless browsing does not exist. What exists is a set of signals you can flatten one by one until you look enough like a real visitor for a given site's threshold.

This guide is the field manual: every signal detectors actually read, how to hide each, how to test yourself honestly, and a clear-eyed take on why this is an arms race you may not want to fight forever.

TL;DR: Detectors fingerprint many signals: navigator.webdriver, ChromeDriver's $cdc_ properties, a "HeadlessChrome" UA, missing plugins/languages, WebGL vendor/renderer, canvas, and UA-vs-client-hint mismatches. Start with --disable-blink-features=AutomationControlled and the stealth plugin, then patch the rest with evaluateOnNewDocument and make UA, headers, locale, timezone, and viewport all consistent. Test against bot.sannysoft.com and CreepJS. It is an arms race — for production scale, a SERP API absorbs it.

How detectors fingerprint a browser

Bot detection is not one check; it is a scorecard. A detector gathers dozens of attributes from your browser — JavaScript properties, rendering quirks, network-level signals, behavior — and asks whether they fit together like a real human's browser or contradict each other like an automated one.

That framing matters because it changes the goal. You are not trying to set one magic flag; you are trying to make a coherent, consistent profile where nothing contradicts anything else. A single mismatch — a Windows User-Agent paired with a Linux WebGL renderer — is more damning than any individual "bot" property, because real browsers are never internally inconsistent.

The signal table

Here are the signals detectors read most, what gives you away, and the direction of the fix.

SignalThe tellFix
navigator.webdrivertrue under automationDisable AutomationControlled + patch to undefined
$cdc_ / cdc_ propsInjected by ChromeDriver/SeleniumUse Puppeteer or a patched driver
User-AgentContains "HeadlessChrome"Set a real Chrome UA
navigator.pluginsEmpty in headlessStealth patch to populate
navigator.languagesEmpty or missingSet and patch to a real value
WebGL vendor/renderer"Google SwiftShader" in headlessSpoof to a real GPU string
Canvas fingerprintIdentical across bot runsAdd subtle per-session noise
UA client hintsHeaders contradict the UAAlign hints with the UA
Timezone / localeMismatch the IP's regionEmulate to match the proxy

You will not perfect every row, and you do not need to — you need to clear a given site's bar. Start at the top, where the cheapest, highest-impact fixes are.

Fix the obvious: webdriver and the launch flag

The single most famous tell is navigator.webdriver, which automation sets to true. The first fix is a launch flag that stops Chrome from advertising it is being controlled:

const browser = await puppeteer.launch({
  headless: 'new',
  args: [
    '--no-sandbox',
    '--disable-blink-features=AutomationControlled',  // the key one
    '--disable-dev-shm-usage',
  ],
});

The --disable-blink-features=AutomationControlled flag removes the most obvious automation marker and, on its own, gets you past the laziest checks. It is necessary but nowhere near sufficient — it is step one of many, not the finish line.

Stealth plugin and manual patches

The puppeteer-extra stealth plugin bundles a couple of dozen patches for the common tells — navigator.webdriver, plugins, languages, WebGL vendor strings, permissions, and more. Turn it on first; it does most of the tedious work:

const puppeteer = require('puppeteer-extra');
const Stealth = require('puppeteer-extra-plugin-stealth');
puppeteer.use(Stealth());

For anything stealth misses, patch it yourself before the page's own scripts run, using evaluateOnNewDocument so your overrides are in place from the first line of JavaScript the site executes:

await page.evaluateOnNewDocument(() => {
  // webdriver should be undefined, not false
  Object.defineProperty(navigator, 'webdriver', { get: () => undefined });

  // Real browsers report languages
  Object.defineProperty(navigator, 'languages', {
    get: () => ['en-US', 'en'],
  });

  // Non-empty plugins array
  Object.defineProperty(navigator, 'plugins', {
    get: () => [1, 2, 3, 4, 5],
  });

  // Spoof the WebGL vendor/renderer away from SwiftShader
  const getParameter = WebGLRenderingContext.prototype.getParameter;
  WebGLRenderingContext.prototype.getParameter = function (p) {
    if (p === 37445) return 'Intel Inc.';                  // UNMASKED_VENDOR_WEBGL
    if (p === 37446) return 'Intel Iris OpenGL Engine';    // UNMASKED_RENDERER_WEBGL
    return getParameter.call(this, p);
  };
});

The reason to run patches in evaluateOnNewDocument rather than after navigation is timing: detection scripts often read these properties on page load, so a patch applied a moment too late has already lost. Get in before the site's JavaScript does.

Consistency is the real trick

Here is the insight that separates scrapers that pass from ones that get caught: detectors increasingly look for contradictions, not individual bad values. Your User-Agent, HTTP headers, client hints, navigator properties, WebGL renderer, timezone, and locale must all tell the same story.

So set them as a matched set, not piecemeal. If your UA says Chrome 124 on Windows, your client hints, platform, and renderer should agree, and your timezone and language should match the region of the IP you are routing through:

const UA =
  'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
  '(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';

await page.setUserAgent(UA);
await page.setExtraHTTPHeaders({ 'Accept-Language': 'en-US,en;q=0.9' });
await page.emulateTimezone('America/New_York');   // match your proxy's region
await page.setViewport({ width: 1366, height: 768, deviceScaleFactor: 1 });

A common self-inflicted wound is rotating a US residential proxy while leaving the browser timezone on UTC and the language on the server's locale — an instant contradiction. Match the browser to the IP, every time. This is the same discipline that keeps a 429 from following you across IPs: a clean address does not save an inconsistent profile.

Test yourself: bot.sannysoft and CreepJS

Do not guess whether you are detectable — measure it. Point your automated browser at a fingerprinting test page and screenshot the result; the page lays out every signal and flags the ones that scream "bot."

const page = await browser.newPage();
await page.goto('https://bot.sannysoft.com', { waitUntil: 'networkidle2' });
await page.screenshot({ path: 'fingerprint-test.png', fullPage: true });

// Or read specific signals programmatically
const report = await page.evaluate(() => ({
  webdriver: navigator.webdriver,
  languages: navigator.languages,
  plugins: navigator.plugins.length,
  userAgent: navigator.userAgent,
  hasChrome: !!window.chrome,
}));
console.log(report);
await browser.close();

bot.sannysoft.com is the quick smoke test; CreepJS is the brutal one, surfacing canvas, audio, and lie-detection signals that catch sloppy spoofing. Fix the red flags, re-run, repeat. This tight loop — test, patch, re-test — is the only honest way to know where you stand, and it is exactly how detectors like DataDome publicly catch stealth setups.

Why it's an arms race

Every fix in this guide is temporary. Detection vendors study the popular stealth tools and ship countermeasures; the stealth community studies the countermeasures and ships new patches. A configuration that passes bot.sannysoft today can trip a commercial detector next month.

There is also a structural leak you cannot patch away: all Chrome DevTools Protocol automation — Puppeteer, Playwright, Selenium via CDP — exposes subtle signals at the protocol level that sophisticated detectors look for. Newer real-browser toolkits chase this, but it keeps moving. The honest summary: stealth is a maintenance commitment, not a one-time setup.

For a one-off scrape or a low-stakes site, that maintenance is fine and the techniques here will carry you. For production data at scale across hardened targets — or sites behind Cloudflare and DataDome like Brave — the upkeep compounds, which is where the build-vs-buy calculation tips. We lay out the broader anti-block landscape in how to scrape Google without getting blocked and compare tooling in scraping Google in Node without Puppeteer.

The alternative to fighting forever

The reason a SERP API exists is precisely this arms race. Instead of maintaining a fingerprint strategy that rots every few weeks, you make a request and someone else keeps the browsers looking human:

import requests

resp = requests.get(
    "https://api.apiserpent.com/api/search",
    headers={"X-API-Key": "sk_live_your_key"},
    params={"q": "best running shoes 2026", "engine": "google", "country": "us"},
)

for r in resp.json()["results"]["organic"]:
    print(r["position"], r["title"], r["url"])

No evaluateOnNewDocument patches, no WebGL spoofing, no monthly re-testing against the latest detector — just clean JSON across Google, Bing, Yahoo, and DuckDuckGo. When your stealth setup starts needing a full-time babysitter, that is the signal to compare the two paths — try a query in the playground first.

Stop maintaining a fingerprint. Just get the data.

Serpent keeps the browsers looking human so you don't have to — clean JSON for Google, Bing, Yahoo, and DuckDuckGo, no stealth maintenance on your side. Get 10 free Google searches on signup, then pay-as-you-go from $0.03 per 10,000 searches at scale, with no subscription.

Get Your Free API Key

Explore: All SERP APIs · Google SERP API · Pricing

FAQ

Does the stealth plugin make my scraper undetectable?

No. The puppeteer-extra stealth plugin patches the most obvious tells like navigator.webdriver and missing plugins, which is enough for many sites, but it does not make you invisible. Advanced detectors check canvas and WebGL fingerprints, TLS signatures, and behavior, and all CDP-based automation leaks in ways stealth cannot fully hide. Treat stealth as a strong baseline, not a cloak.

What is the number one giveaway of a headless browser?

Historically it was navigator.webdriver being true, which is why disabling the AutomationControlled blink feature is step one. Today the bigger tells are mismatches — a User-Agent that says Chrome on Windows while the WebGL renderer says SwiftShader, or client-hint headers that do not match the UA. Consistency across every signal matters more than any single patch.

How do I test if my scraper is detectable?

Point your automated browser at a fingerprinting test page like bot.sannysoft.com or CreepJS and screenshot the result. They show navigator.webdriver, plugins, WebGL vendor and renderer, languages, and more, flagging the values that reveal automation. Fix the red flags, re-test, and repeat — it is the fastest feedback loop available.

Is fighting bot detection a losing battle?

It is an arms race. Detection vendors and stealth tooling update against each other constantly, so a setup that passes today can fail next month, and maintaining it is ongoing work. For a one-off scrape it is winnable; for production data at scale, many teams decide the maintenance is not worth it and use a managed API that absorbs the fight.