Privacy Tech

navigator.webdriver Detected After Stealth Plugin: 7-Signal Teardown

BC

BotCloud Team

May 4, 2026·9 min read

The Question Behind the Question

You installed puppeteer-extra-plugin-stealth. You ran your script. BotD flagged you as a bot. FingerprintJS returned a low trust score. browserscan.net showed a red shield. You opened DevTools, typed navigator.webdriver, and saw false. Stealth was supposed to fix this. So what gives?

navigator.webdriver is one of seven automation signals that detection systems read. Stealth plugins patch it well. They patch maybe two or three of the others. The rest leak through, and modern detection scores look at all of them in combination.

This post walks through all seven signals, gives the detection code for each, and explains where the patch chain breaks down.

The Seven Signals

These are the JavaScript-side and HTTP-side signals that detection systems check first. There are more (canvas pixel patterns, audio context fingerprints, network-stack JA4, behavioral timing) but these seven are where the cheapest, most reliable detection happens.

1. navigator.webdriver

The original automation marker, defined in the WebDriver W3C spec.

const webdriver = navigator.webdriver;
// real Chrome: undefined or false
// vanilla Playwright/Puppeteer: true
// stealth-plugin patched: false

Stealth plugins patch this reliably. This is the patch everyone gets right.

2. CDP-Injected Globals on window

When Playwright or Puppeteer attaches via CDP, it injects internal helper globals into the page context. Names vary by version, but the common ones:

const leaked = Object.keys(window).filter((k) =>
    /__playwright__binding__|__pwInitScripts|__playwright_builtins__|__puppeteer_evaluation_script__|cdc_/.test(k),
);
// real Chrome: []
// vanilla Playwright: ["__playwright__binding__", "__pwInitScripts", "__playwright_builtins__"]

puppeteer-extra-plugin-stealth does not currently patch these, because they are dynamically named per session and per version. If you delete them on every page navigation manually, you can hide them, but a single race condition before your delete script fires leaks them. Detection scripts read window early.

3. The User-Agent String

Headless Chrome historically appended the literal string HeadlessChrome to the user agent, which made detection trivial. Newer headless modes do not, but plugins sometimes ship user agents that disagree with the actual browser version, or that contradict the Sec-CH-UA client hint headers (signal 4).

// the leak
navigator.userAgent.includes('HeadlessChrome');
// the modern variant — UA Chrome version vs Sec-CH-UA version mismatch
const uaVersion = navigator.userAgent.match(/Chrome\/(\d+)/)?.[1];
const hintsVersion = (await navigator.userAgentData?.getHighEntropyValues(['fullVersionList']))
    ?.fullVersionList?.find((v) => v.brand === 'Google Chrome')?.version;
// uaVersion and hintsVersion should agree to the major

4. Sec-CH-UA Client Hints

Modern Chrome ships User-Agent Client Hints (Sec-CH-UA, Sec-CH-UA-Mobile, Sec-CH-UA-Platform, Sec-CH-UA-Full-Version-List). These are sent as request headers AND exposed via navigator.userAgentData. Three things detection looks for:

  • The headers exist at all (some patched browsers strip them).
  • The Sec-CH-UA brand list matches the user agent string.
  • navigator.userAgentData.getHighEntropyValues(...) returns values consistent with the OS and Chrome version.

Stealth plugins handle the basic case. Inconsistencies appear when the underlying browser binary is older or newer than what the spoofed UA claims.

5. navigator.plugins Length

Real Chrome on desktop reports a non-empty plugins collection (typically 5 entries: PDF Viewer, Chrome PDF Viewer, Native Client, etc., depending on version). Headless Chrome has historically reported zero.

const pluginsLength = navigator.plugins.length;
// real Chrome on desktop: 3-5
// headless Chrome (older): 0
// stealth-plugin patched: typically 5 (synthesized)

Stealth plugins synthesize a fake plugin list. Detection counters check that the synthesized plugins have the right name, description, filename, and that they iterate correctly under for...in and for...of (some patches break iteration).

6. The chrome Global Object

Real Chrome exposes a window.chrome object with chrome.runtime, chrome.loadTimes, and various other API surfaces. The shape of this object is rich and deeply-nested. Headless Chrome historically exposed an empty or partial version.

const hasRuntime = typeof window.chrome?.runtime !== 'undefined';
const hasLoadTimes = typeof window.chrome?.loadTimes === 'function';
// real Chrome browser context: both true
// headless Chrome: usually one or both missing

Stealth plugins synthesize window.chrome. Detection scripts test for specific nested properties (chrome.runtime.PlatformOs, chrome.app.InstallState) that the synthesized version may or may not include.

7. Permissions API Inconsistency

The Permissions.query API returns the state of a given permission. Real browsers and headless Chrome return different defaults for 'notifications':

const result = await navigator.permissions.query({ name: 'notifications' });
// real Chrome: state matches Notification.permission ("default", "granted", or "denied")
// vanilla headless Chrome: returns "denied" while Notification.permission is "default"

This three-way inconsistency (permissions.query vs Notification.permission) is one of the cleanest tells. Stealth plugins fix it. But the underlying issue is that any time a synthesized API surface is patched in JavaScript, there is a risk that a Proxy gets exposed via Function.prototype.toString or Reflect.ownKeys, which is the next layer of detection.

What This Looks Like Inside a Real Probe

Here is the actual probe output from a Playwright session running against iphey.com, captured from inside the page context:

{
  "webdriver": false,
  "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
  "plugins": 5,
  "languages": ["en-US"],
  "platform": "MacIntel",
  "hardwareConcurrency": 14,
  "leaks": []
}

Read this against the seven signals:

  • Signal 1 (webdriver): false, clean.
  • Signal 2 (CDP globals): the leaks array filtered by playwright, puppeteer, and cdc_ is empty. Clean.
  • Signal 3 (UA string): no HeadlessChrome. Clean.
  • Signal 5 (plugins.length): 5, the realistic value for desktop Chrome 146 on macOS.
  • Signal 6 and 7 are not in this probe summary, but iphey's per-card breakdown reports the browser, hardware, and software signals as consistent.

The probe was run from a Playwright script attached to a Chromium build that ships with engine-level patches rather than runtime stealth injection.

Where Stealth Plugins Break Down

Stealth plugins patch JavaScript surfaces from inside the page context after navigation. This works for signals where the surface is a single property or a single function. It struggles with three categories:

  1. Surfaces that exist before user code runs. CDP-injected globals (signal 2) appear when the CDP target attaches, which is before any page-level init script can delete them. A detection script with a sufficiently early read window catches them.

  2. Surfaces that are read multiple times. If the page reads navigator.plugins twice and the patched value differs between reads (because the second read accidentally hits the synthesized version while the first hit the real one), the inconsistency is itself the tell.

  3. Surfaces that the patch itself creates. Wrapping a function with Proxy to spoof its return value leaves a Proxy detectable via toString audits or by checking Function.prototype.toString.call(fn).includes('[native code]'). Plugin patches often add their own native-code spoofing for toString, and now you are spoofing the spoofer.

For each signal the plugin patches, the detection ecosystem has had time to write a counter-check. The race is asymmetric: a plugin author maintains one project; detection vendors are paid by hundreds of customers to find new tells.

The Engine-Level Alternative

Instead of patching the running browser from JavaScript, you can ship a Chromium build where the leaky surfaces never exist. The CDP attachment does not produce the runtime artifacts. The navigator.webdriver getter is removed at the C++ layer rather than overridden in JS. The plugins array is loaded from a real-browser-derived profile rather than synthesized.

When a detection script reads from this build:

  • Signal 1: navigator.webdriver is genuinely undefined, not patched-undefined.
  • Signal 2: there is no __playwright__binding__ to delete, because the C++ side never installs it.
  • Signal 5: navigator.plugins returns the same values regardless of read order or context.
  • Signal 7: the Permissions API state matches Notification.permission because both come from the same underlying Chromium engine flag, not two separate spoofers.

The race becomes symmetric again: detection vendors are looking for tells; the build has no tells to find at the JavaScript layer.

Diagnostic Checklist

If a stealth-patched browser is still being detected, work through these in order:

  1. Confirm navigator.webdriver is actually false. It is the cheap default check. Anything else is suspicious if this is true.

  2. List all globals on window that match automation patterns. Run:

    Object.keys(window).filter((k) =>
        /playwright|puppeteer|cdc_|webdriver|automation/i.test(k),
    );
    

    Anything non-empty is a strong signal.

  3. Check navigator.plugins iteration. Iterate with for...of, for...in, and Array.from. The lengths and contents must agree.

  4. Verify Sec-CH-UA matches the User-Agent. Both the response headers and navigator.userAgentData.getHighEntropyValues must be consistent.

  5. Check Permissions API consistency. (await navigator.permissions.query({name:'notifications'})).state vs Notification.permission. They must match.

  6. Run the detection site of choice. creepjs, browserscan.net, iphey.com, pixelscan.net, and BotD each weight signals differently. Knowing which signal flagged you tells you which patch is failing.

  7. Read the response from siteverify or the equivalent server-side endpoint. Many WAFs return their verdict in a header or JSON field. The verdict is more diagnostic than the page-level redirect.

Quick FAQ

Q: Does --disable-blink-features=AutomationControlled fix this?

It removes the navigator.webdriver = true setter, fixing signal 1 and not the other six. Stealth plugins do this and more, and they still get detected.

Q: Why do some detection sites pass and others fail?

Different detection vendors weight signals differently. BotD is open source and you can read its checks. FingerprintJS Pro and Cloudflare are closed and weight aggressively against the cluster of weak signals. A single signal's pass or fail is not predictive of the composite verdict.

Q: Will switching from Puppeteer to Playwright help?

Marginally. Both attach via CDP and both leak version 2 globals. Playwright's globals have different names from Puppeteer's, so a detection script that hardcodes Puppeteer-specific names will miss Playwright's, and vice versa. This is a temporary advantage; serious detection looks for the pattern, not the specific names.

Q: What about nodriver or patchright?

Both patch a wider surface than puppeteer-extra-plugin-stealth and are actively maintained. They handle a few of the seven signals better. The same fundamental constraint applies: any patch applied at the JavaScript layer leaves a patch shape that a sufficiently motivated detection script can find.

Bottom Line

navigator.webdriver is the loudest automation signal. It is also the easiest to patch, which is why every stealth plugin handles it correctly. The fact that you were detected after patching it means the detection script read one of the other six signals, found it leaking, and gave you a low trust score from that.

Patch each of the seven from the JavaScript layer and you are still in a chase: every detection update creates a new tell that the patch chain has to respond to. Patch them at the engine layer, in C++, before they reach JavaScript at all, and the chase stops because the surface is not there to read.

If you want a Chromium build that handles these at the engine layer and exposes a per-context fingerprint API, see BotCloud's cloud browser.

#webdriver#stealth#automation#playwright#puppeteer#fingerprint

Share this post