Back to Blog
Deployment

How Websites Detect Browser Automation and How to Prevent It

How automation frameworks like Playwright and Puppeteer are detected through JavaScript signals, and engine-level techniques to prevent detection.

Introduction

When you automate a browser with Playwright, Puppeteer, or Selenium, the browser exposes signals that identify it as programmatically controlled. The navigator.webdriver property is set to true. Framework-specific objects appear in the JavaScript environment. Console and debugging protocols leave traces. Headless mode behaves differently from headed mode in subtle ways.

These signals exist because automation frameworks need hooks into the browser to function. The problem is that these same hooks are visible to any JavaScript running on the page. BotBrowser addresses this by controlling automation signals at the engine level, before any page code executes. No stealth plugins, no JavaScript patches, no post-initialization cleanup.

Why Automation Signal Prevention Matters

Modern websites collect a variety of browser signals to build a profile of each visitor. Among the most reliable signals for identifying automated browsers are the artifacts left by automation frameworks. A navigator.webdriver value of true is an unambiguous indicator. Framework binding objects in the global scope confirm programmatic control. CDP (Chrome DevTools Protocol) session activity patterns differ from normal browsing.

The challenge is that these signals are deeply embedded in how automation frameworks operate. Playwright injects __playwright__binding__ into the page context. Puppeteer creates __puppeteer_evaluation_script__ objects. Selenium sets navigator.webdriver through the WebDriver protocol. These are not bugs; they are design requirements of how each framework communicates with the browser.

Stealth plugins attempt to clean up after the framework has already set these values. They override navigator.webdriver with a JavaScript getter, delete framework objects from the global scope, and patch other telltale properties. This approach has a fundamental weakness: the cleanup happens in JavaScript, after the page context is initialized. The original values exist briefly before being overwritten, and the cleanup code itself is detectable.

Technical Background

The navigator.webdriver property was introduced by the W3C WebDriver specification. When a browser is controlled via the WebDriver protocol, this property returns true. It was designed as a transparency mechanism so websites can know when they are being accessed by automated tools.

In standard Chromium, this value is set at the C++ level during browser initialization when the --enable-automation flag is present (which Playwright and Puppeteer add by default). Once set, it is readable through navigator.webdriver in every JavaScript context.

CDP Artifacts

The Chrome DevTools Protocol enables external tools to control the browser. When a CDP session is active, several signals become observable:

  • Runtime.enable and Console.enable CDP domains, when activated, change the behavior of console.log and error stack traces in ways that page JavaScript can detect.
  • CDP session connections appear on specific WebSocket endpoints.
  • Certain browser behaviors change when CDP domains are active, such as how error stack traces are generated and whether console messages are forwarded.

Headless Mode Differences

Standard Chromium's headless mode differs from headed mode in several observable ways. The navigator.plugins array may be empty. WebGL may fall back to software rendering with different vendor/renderer strings. Window dimensions may have unusual values. These differences are not present in BotBrowser because profile values are applied uniformly in both modes.

Framework Injection Points

Each automation framework injects code at specific points:

  • Playwright: Injects binding functions (__playwright__binding__) and initialization scripts into every new page context.
  • Puppeteer: Injects evaluation script helpers and CDP session management code.
  • Selenium: Uses the WebDriver protocol which sets browser-level automation flags.
Standard Chromium navigator.webdriver = true __playwright__binding__ present Console.enable active Headless mode detectable CDP WebSocket visible Runtime.enable stack changes Signals visible to pages BotBrowser navigator.webdriver = false No framework objects leaked Console forwarding controlled Headless = headed output CDP artifacts suppressed Engine-level control Signals controlled at C++ level

Common Approaches and Limitations

Stealth Plugins

Libraries like puppeteer-extra-plugin-stealth and similar tools attempt to patch automation signals after the browser has started. They work by overriding JavaScript properties, deleting framework objects, and modifying other detectable values.

The fundamental problem is timing. These patches apply after the browser has already initialized with automation flags set. There is a window during which the original values are exposed. Additionally, the patching code itself introduces detectable artifacts. Property descriptors changed by Object.defineProperty behave differently from native properties. Deleted objects leave prototype chain irregularities.

Custom Chromium Builds

Some teams compile custom Chromium builds with automation flags removed. This eliminates the signals at the source but requires maintaining a Chromium fork, which is a significant engineering investment. Each Chrome update requires rebasing and resolving merge conflicts. The build itself can take hours on powerful hardware.

User-Agent Switching

Changing the User-Agent string does not address automation signals. navigator.webdriver, CDP artifacts, and framework objects are independent of the User-Agent. This approach creates additional inconsistencies between the claimed browser identity and the actual browser behavior.

Headless Detection Countermeasures

Some approaches focus specifically on making headless mode undetectable by patching navigator.plugins, adjusting WebGL values, and modifying other headless-specific properties. This addresses one category of signals while ignoring others. It also fails against tests that check for internal consistency rather than specific values.

BotBrowser's Approach

BotBrowser solves automation signal exposure at the engine level. The browser binary itself is modified to control these signals before any JavaScript context is created. This is fundamentally different from post-initialization patching.

In BotBrowser, navigator.webdriver returns false regardless of whether the browser is controlled by an automation framework. This is not a JavaScript override. The value is set at the C++ level during browser initialization, which means it is indistinguishable from a normal browser that is not being automated.

Console and Debugger Suppression

The --bot-disable-console-message flag (ENT Tier1, default true) prevents Console.enable and Runtime.enable from being activated by automation frameworks. This blocks a category of signals based on error stack trace behavior changes that occur when these CDP domains are active.

The --bot-disable-debugger flag prevents JavaScript debugger statements from pausing execution. Some pages use debugger statements in an infinite loop to detect when DevTools or CDP sessions are active.

Headless and Headed Parity

BotBrowser produces identical API output in headless and headed mode. The profile's values for navigator.plugins, WebGL renderer, screen dimensions, and all other properties are the same regardless of execution mode. There is no "headless fingerprint" to detect.

Clean Playwright Integration

const { chromium } = require('playwright-core');

(async () => {
  const browser = await chromium.launch({
    executablePath: '/opt/botbrowser/chrome',
    args: [
      '--bot-profile=/opt/profiles/profile.enc',
      '--proxy-server=socks5://user:pass@proxy.example.com:1080',
      '--bot-disable-debugger',
      '--bot-disable-console-message',
    ],
    headless: true,
  });

  const context = await browser.newContext();
  const page = await context.newPage();

  // Clean up Playwright-specific artifacts from page context
  await page.addInitScript(() => {
    delete window.__playwright__binding__;
    delete window.__pwInitScripts;
  });

  await page.goto('https://example.com');
  // Your automation logic here
  await browser.close();
})();

Clean Puppeteer Integration

const puppeteer = require('puppeteer-core');

(async () => {
  const browser = await puppeteer.launch({
    executablePath: '/opt/botbrowser/chrome',
    args: [
      '--bot-profile=/opt/profiles/profile.enc',
      '--proxy-server=socks5://user:pass@proxy.example.com:1080',
      '--bot-disable-debugger',
      '--bot-disable-console-message',
    ],
    headless: true,
    defaultViewport: null,
  });

  const page = await browser.newPage();
  await page.goto('https://example.com');
  // Your automation logic here
  await browser.close();
})();

Production Configuration

A production-ready launch includes several complementary flags:

chromium-browser \
  --bot-profile="/opt/profiles/windows-chrome-131.enc" \
  --proxy-server=socks5://user:pass@proxy.example.com:1080 \
  --bot-disable-debugger \
  --bot-disable-console-message \
  --bot-always-active \
  --bot-inject-random-history \
  --bot-port-protection \
  --user-data-dir="/data/session-1" \
  --headless

--bot-always-active keeps windows in an active state even when unfocused, which is the normal behavior of a user's primary browser. --bot-inject-random-history adds synthetic browsing history for session authenticity. --bot-port-protection prevents pages from scanning localhost ports.

Configuration and Usage

What You Can Remove

With BotBrowser, several common workarounds become unnecessary:

WorkaroundWhy It Is No Longer Needed
Stealth plugins (puppeteer-extra-plugin-stealth)Automation signals controlled at engine level
JavaScript property overrides for navigator.webdriverSet to false natively in C++
User-Agent spoofing middlewareProfile defines complete, consistent UA
Custom headless detection patchesHeadless and headed mode produce identical output
CDP artifact cleanup scriptsCDP signals suppressed at engine level
"Undetected" browser wrapper librariesNot needed; BotBrowser handles all signals

Use playwright-core instead of playwright. The full playwright package bundles its own Chromium binary, which is unnecessary when you are using BotBrowser. playwright-core provides the API without the bundled browser.

Similarly, use puppeteer-core instead of puppeteer for the same reason.

Viewport Configuration

Always set defaultViewport: null in Puppeteer to prevent it from overriding the profile's screen dimensions:

const browser = await puppeteer.launch({
  executablePath: '/opt/botbrowser/chrome',
  args: ['--bot-profile=/opt/profiles/profile.enc'],
  headless: true,
  defaultViewport: null,  // Preserve profile dimensions
});

In Playwright, viewport is controlled through the browser context and does not override profile values by default.

Verification

Verify that automation signals are properly controlled:

const results = await page.evaluate(() => ({
  webdriver: navigator.webdriver,
  webdriverDescriptor: Object.getOwnPropertyDescriptor(
    Navigator.prototype, 'webdriver'
  ),
  playwright: typeof window.__playwright__binding__,
  puppeteer: typeof window.__puppeteer_evaluation_script__,
  chromeApp: typeof window.chrome?.app,
  pluginCount: navigator.plugins.length,
  languages: navigator.languages,
}));

console.log('Automation signal check:', JSON.stringify(results, null, 2));

Expected output:

  • webdriver: false
  • webdriverDescriptor.get: should be a native function
  • playwright: "undefined"
  • puppeteer: "undefined"
  • pluginCount: greater than 0
  • languages: matching profile and locale settings

Navigate to CreepJS and check that no automation or lie indicators are flagged.

Best Practices

Use the minimal set of flags needed. Start with --bot-profile and add --bot-disable-debugger and --bot-disable-console-message for production deployments.

Clean Playwright artifacts from page context. Use page.addInitScript() to delete __playwright__binding__ and __pwInitScripts. BotBrowser handles engine-level signals, but framework objects in the page context should still be removed.

Set defaultViewport: null in Puppeteer. This preserves the profile's screen dimensions and device pixel ratio.

Match proxy location to profile. A Windows Chrome profile paired with a US proxy creates a consistent identity. The same profile with a Japanese proxy but English locale creates a mismatch. Let BotBrowser auto-derive locale settings from the proxy, or override them explicitly.

Rotate profiles across sessions. Using the same profile for all instances creates a cluster of identical fingerprints. Use --bot-profile-dir for diversity.

Test regularly. Websites update their signal collection methods. Regular verification ensures your setup remains effective.

Frequently Asked Questions

Do I still need stealth plugins with BotBrowser?

No. BotBrowser controls automation signals at the engine level. Stealth plugins are redundant and may introduce their own detectable artifacts.

Does BotBrowser work with Selenium?

BotBrowser works best with Playwright and Puppeteer, which use CDP for browser communication. Selenium uses the WebDriver protocol, which can introduce additional automation signals. If you must use Selenium, BotBrowser still controls navigator.webdriver and headless mode signals, but CDP-specific protections may not apply.

What about CDP detection through WebSocket enumeration?

BotBrowser's --bot-disable-console-message flag prevents the CDP domains that are most commonly used for detection (Console.enable, Runtime.enable) from being activated by frameworks. The debugging port itself should only be exposed to trusted networks.

Can pages detect that I am using a proxy?

Proxy detection is a network-level concern, separate from automation signal detection. BotBrowser auto-derives timezone and locale from the proxy IP to maintain consistency. For WebRTC, use --bot-webrtc-ice to control ICE candidate exposure.

What is the performance impact of these protections?

Negligible. BotBrowser's automation signal control operates at initialization time and does not add per-request or per-navigation overhead. Benchmark data shows less than 1% performance difference from stock Chrome.

Does --bot-disable-debugger affect my ability to debug?

Yes, debugger statements in page JavaScript will be ignored. This does not affect CDP-based debugging (breakpoints set through the protocol still work). The flag specifically prevents pages from using debugger as a detection mechanism.

How does BotBrowser handle new detection methods?

BotBrowser is updated regularly to address new detection signals. The engine-level approach means most new signals can be controlled without changes to your automation code. Keep your BotBrowser binary updated to the latest release.

Can I verify the protection programmatically in CI/CD?

Yes. Use the verification script from the Configuration section as part of your deployment pipeline. Assert that navigator.webdriver is false and that no framework objects are present in the page context.

Summary

Automation signal prevention requires engine-level control, not JavaScript patches applied after initialization. BotBrowser handles navigator.webdriver, CDP artifacts, headless mode differences, and console/debugger signals at the C++ level, eliminating the need for stealth plugins and wrapper libraries.

For setup guides, see Getting Started with Playwright and Getting Started with Puppeteer. For production deployment, see Docker Deployment Guide and Headless Server Setup. For profile organization, see Profile Management.

#automation#detection#webdriver#deployment#privacy