How to Find Headless Browser Detection Leaks in Your Stack

headless browser detection - How to Find Headless Browser Detection Leaks in Your Stack

Headless browser detection is not a single test. It is a pile of small ones, and most automation setups fail the same three before the first page finishes loading. You do not need a vendor dashboard to see them, because everything that matters is readable from the browser you already have open. This post gives you four probes, ranks what they find by how fast each one gets you blocked, and patches the ones worth patching. It also draws a line most write-ups skip. A clean fingerprint lowers how often you get challenged, and it never gets you to zero.

What you need

  • Chrome or Chromium, and a way to run JavaScript in the page: DevTools by hand, or your driver’s evaluate call.
  • Any driver you already use. The probes are plain JavaScript, so Playwright, Puppeteer and Selenium all work unchanged.
  • Python 3.10 or newer if you want to run the patch and the solve example at the end.
  • CapSkip installed and running, for the last section only. Follow the setup guide and note which connection mode you picked, because it decides what host your code points at.

One thing is worth settling before you start. The solver does not have to live on the same machine as the browser. Local mode listens on the loopback address and serves that device only, while Server mode listens on your network or public IP so a second box, a VPS or a hosted runner can reach the same instance over the API. Both are described under connection settings, and there is a short section on the server case further down.

Step 1: read the signals that decide it early

Start with the cheap ones. Every commercial anti-bot script reads these within its first few milliseconds, because they are synchronous property lookups with no network cost. Paste this into the console of the page that is actually blocking you rather than a blank tab, since some values depend on the document.

// Paste into DevTools, or hand it to your driver's evaluate call
// so it runs in the real page context rather than a fresh tab.
const leaks = {
  webdriver: navigator.webdriver,
  plugins: navigator.plugins.length,
  languages: navigator.languages.join(","),
  cores: navigator.hardwareConcurrency,
  memory: navigator.deviceMemory,
  platform: navigator.platform,
  hasChrome: !!window.chrome,
  hasRuntime: !!(window.chrome && window.chrome.runtime),
};
console.table(leaks);   // read every row, not just the first

A real Chrome session reports the webdriver property as undefined, three to eight entries in the plugin list, at least two accepted languages, a core count matching the machine, a platform string such as Win32 or MacIntel, and a populated window.chrome object with a runtime on it. An automated container tends to report true, zero, one language, two cores, Linux x86_64, and nothing at all for the last two.

The webdriver property is the one people already know about, and it is the only entry in that list which is there on purpose. It is standardised: the W3C WebDriver specification requires a conforming driver to set a flag that this property exposes, and MDN documents the same behaviour. Its presence is not a bug somebody forgot to fix. It is the browser doing exactly what the spec tells it to.

Read the rows together rather than one at a time, because detection scripts compare them for agreement. A Windows user agent sitting next to a Linux platform string is a far stronger signal than either value alone, and it is the mistake almost every hand-rolled patch makes first.

Step 2: look for driver artifacts left in the window

Chromedriver and the Selenium injection layer leave named globals behind. They are trivially enumerable, nobody legitimate has them, and finding one is conclusive rather than probabilistic.

// Injected globals from Chromedriver and Selenium. A clean
// browser prints the word clean and nothing else.
const prefixes = ["__cdc", "__selenium", "__webdriver", "__driver"];
const found = Object.keys(window).filter(
  (k) => prefixes.some((p) => k.startsWith(p))
);
// Two more that Chromedriver adds under fixed names.
for (const name of ["domAutomation", "domAutomationController"]) {
  if (window[name] !== undefined) found.push(name);
}
console.log(found.length ? found : "clean");

A hit here is the most valuable thing the audit can return, because there is no ambiguity to argue with. A randomised name beginning with the cdc prefix is the classic Chromedriver marker, and the usual answer is to stop patching it by hand and switch to a driver build that removes it for you. We wrote that route up in the guide to handling CAPTCHAs in undetected-chromedriver, and the broader Selenium picture lives on the Selenium CAPTCHA solver page, which goes through the driver options in more depth.

Step 3: ask the GPU what it thinks it is

WebGL reports the graphics vendor and renderer as plain strings, and a container without a GPU has to answer honestly with the name of its software fallback. This is the loudest thing headless Chrome in Docker gives away, and no amount of navigator patching touches it.

// The renderer is exposed as a plain string, so read it directly.
const gl = document.createElement("canvas").getContext("webgl");
const dbg = gl.getExtension("WEBGL_debug_renderer_info");
console.log(
  gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL),
  gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL)
);
// SwiftShader, llvmpipe, Mesa or VMware means no real GPU here.

Seeing SwiftShader is not fatal, and it is not something you can honestly spoof away either, because a spoofed renderer string still has to agree with the pixels your canvas produces. In a container against an aggressive target, the realistic options are giving that container a real GPU or moving the job to a machine that has one.

Step 4: check the Worker context separately

This probe catches half-finished stealth setups and almost nobody runs it. Patches applied to the page window do not propagate into a Web Worker, which gets its own fresh navigator object, so a setup that looks clean in the console can still answer truthfully one layer down.

// A Worker gets its own navigator, untouched by page patches.
const src = "postMessage(navigator.webdriver)";
const url = URL.createObjectURL(new Blob([src]));
new Worker(url).onmessage = (e) => console.log("worker says:", e.data);
// true here while the page says undefined is a mismatch,
// and a mismatch is a worse signal than either value alone.

Take that last comment seriously, because consistency is what these scripts score. A browser answering undefined in one context and true in another has told the detector two things: that it is automated, and that somebody tried to hide it. The second is what pushes a session from scored to blocked.

Which headless browser detection signals matter most

Not every finding deserves your afternoon. Ranked by how fast each one acts:

SignalWhat a real session looks likeWeight
Driver globals left in the window objectNone present at allCritical: conclusive on load
The navigator webdriver propertyUndefinedCritical: read within milliseconds
Platform, user agent and language agreementAll three describe one machineCritical: a mismatch beats either value
WebGL renderer stringA named GPU, not a software fallbackHigh: challenge within seconds
Synthetic events reporting isTrusted as falseTrue, because the input came from the browserHigh: fires on first interaction
TLS and HTTP/2 fingerprintMatches the Chrome build you claim to beHigh, and invisible to every probe above
Empty plugin list, one language, two coresPopulated and plausibleMedium: feeds a score
Font count and audio fingerprintA desktop font set, a non zero audio hashMedium: rarely decisive alone

The isTrusted row is widely misunderstood, so it is worth being precise. Calling the click method on an element from JavaScript produces an event reporting isTrusted as false, which is easy to spot. Driving the same click through Playwright, Puppeteer or Selenium does not, because those go through the browser’s own input pipeline. So it is a leak in hand-rolled DOM scripting, not in your driver.

Step 5: patch the critical ones

Two rules before any code. Patch early, before page scripts run, or the detector reads the original value and your fix arrives too late. And patch narrowly, because a clumsy override is itself detectable: overwriting a native function leaves its source visible to anyone calling toString on it, turning a hidden signal into an obvious one.

# pip install playwright
from playwright.sync_api import sync_playwright

PATCH = """
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
window.chrome = window.chrome || { runtime: {} };
"""

with sync_playwright() as p:
    # Real Chrome leaks less than the bundled Chromium build.
    browser = p.chromium.launch(channel="chrome", headless=False)
    page = browser.new_page(locale="en-US")
    # add_init_script runs before any page script reads navigator.
    page.add_init_script(PATCH)
    page.goto("https://example.com/page-with-recaptcha")
    # Re-run the Step 1 probe here to confirm the patch landed.

Three cheap wins are missing from that snippet because they are configuration rather than code. Use the real Chrome channel instead of bundled Chromium. Keep a persistent profile directory between runs, so each session arrives with cookies and history instead of looking newborn. And match the locale and timezone to wherever your traffic appears to come from. Those three move more score than any navigator override, and none of them can be caught lying. If you want the framework specifics, the Playwright CAPTCHA solver page covers the wiring on that side, and Puppeteer users get the same walkthrough on the Puppeteer CAPTCHA solver page for their own driver setup.

What none of this fixes

Three categories sit outside the browser, so every probe above is blind to them. Your TLS handshake gets fingerprinted before a byte of JavaScript runs, which is why a Python HTTP client fails checks that the same request from Chrome sails through. Your IP carries the reputation of its network. And your behaviour gets scored across the session: request rate, navigation order, how fast forms get filled. So headless browser detection is only ever part of why you got stopped.

All of this changes the odds of being challenged and none of it removes challenges. A real browser on a residential connection still meets a reCAPTCHA or a Turnstile widget regularly, because plenty of sites challenge every visitor on some routes whatever the score says.

Solving the challenge you still get

Once the widget appears, fingerprinting has stopped being the problem and the token has become it. CapSkip runs on your own hardware, answers on a 2captcha-compatible API, and hands back a token you inject and submit yourself.

# pip install capskip
from capskip import CapSkip

# Local mode. In Server mode this is the solver box's address.
solver = CapSkip(host="127.0.0.1", port=8080)

# One call covers v2, Invisible, Enterprise and v3 as options.
result = solver.recaptcha(
    sitekey="YOUR_SITEKEY",
    url="https://example.com/page-with-recaptcha",
)

# Inject the token into the field the page submits with the form.
page.evaluate(
    "t => document.getElementById('g-recaptcha-response').value = t",
    result["code"],
)

print(result["code"][:24])   # token, ready to submit

Because the solver is a local daemon rather than a metered service, retrying a failed solve costs nothing, which matters more here than it first looks. Fingerprint work is iterative, and solving the same challenge forty times while you test one patch is an expensive way to debug against a per-solve API.

Running the solver on a server instead

Browser fleets usually do not run on a laptop, and the solver does not have to either. The connection settings offer two modes, and the only difference is which interface the API listens on.

ModeListens onUse it when
Local127.0.0.1, that device onlyBrowser and solver share one machine
ServerYour network address or public IPA second box, a VPS, a container host or a hosted CI runner needs to reach it

In Server mode you point the SDK host at that address instead of the loopback one and change nothing else, so a fleet of twenty containers can share a single solver. A static public IP is worth having when the callers sit outside your network, since the address ends up in configuration. Full details are under connection settings in the setup guide. Server mode is still your own hardware and still unmetered, so it changes where the solver runs and nothing about what it costs.

FAQ

Is the new headless mode still detectable?

Yes, though less crudely than the old one. Chrome’s newer headless mode shares the normal browser binary, so the giveaway user agent string and several missing APIs are gone. The webdriver property is still set, driver globals are still injected, and a container with no GPU still reports a software renderer. Headless is one signal among many rather than an instant fail.

Is a stealth plugin enough on its own?

It handles the well-known JavaScript properties and it is worth using. It cannot touch your TLS fingerprint, your IP reputation or your request pattern, and its patches are public, so detection vendors test against them directly. Run the four probes after installing one rather than assuming the job is finished.

My browsers run on hosted runners. Can they still reach the solver?

Yes, with CapSkip in Server mode. A hosted runner cannot see your loopback address, so switch the solver to listen on your network or public IP and point the SDK host at that instead. One instance serves every runner, no tunnel is involved, and a static public IP keeps the configuration stable. Both modes, and the port each one binds, are described in the setup guide under connection settings where the switch between them is explained.

Will a clean fingerprint stop the CAPTCHAs?

It reduces them and it will not end them. Many sites challenge on a route rather than on a score, so a perfect browser still meets a widget on the login or checkout path. Plan for both: lower the rate at which you get challenged, and keep something in the pipeline that answers the ones arriving anyway.

Run the probes before you change anything

A headless browser detection audit takes about ten minutes and usually finds two problems rather than twenty. Clear out the driver globals, make your navigator values agree with each other, and check the Worker context so your patches are not contradicting themselves. Then treat the challenges that still arrive as a separate job with a separate tool. Running a captcha solver on hardware you own handles those without a per-solve bill, which is the shape most scraping fleets end up wanting, as the notes on using a CAPTCHA solver for web scraping spell out for a worker pool.