Technical 10 min read

Headless Browser Detection: What Test Pages Check

Public test pages reveal why default Playwright and Selenium fail headless checks—webdriver, WebGL, plugins, CDP—and what stealth setups change.

FT
FineData Engineering · Editorial Policy
| | Updated July 28, 2026

Headless Browser Detection: What Test Pages Check

Open a stock Playwright or Selenium Chromium session and navigate to a page built specifically to score automation tells. Rows light up red. That is not a mystery: default headless Chrome advertises that it is under remote control, and public diagnostic suites exist to make those advertisements visible.

Pages such as bot.sannysoft.com, browserleaks.com/javascript, and research demos in the spirit of Antoine Vastel’s headless-detection work are published for exactly this purpose. Visiting them is probing a diagnostic tool as intended. They do not protect a merchant checkout or a login wall; they teach you what a JavaScript detection surface looks like before any commercial anti-bot vendor (Cloudflare, DataDome, PerimeterX/HUMAN, Akamai) layers network reputation and behavioral scoring on top.

This piece maps the checks those public suites commonly surface, explains why out-of-the-box automation fails them, and contrasts that with what a carefully configured stealth Chromium changes at the engine and document level. It is an educational map of the surface—not a how-to against a named production site.

Why public test pages matter

Commercial bot management stacks combine several layers: IP and ASN reputation, TLS and HTTP/2 fingerprints, header consistency, JavaScript environment probes, and interaction timing. The browser layer is the part you can inspect locally without touching anyone else’s product. A suite like Sannysoft renders a pass/fail table for classic automation markers. BrowserLeaks exposes navigator, WebGL, canvas, fonts, and related APIs in human-readable form. Research demos document inconsistencies between what a real Chrome session exposes and what a CDP-driven headless session leaks.

If you want the wider vendor stack (network through behavior), see how Cloudflare, DataDome, and PerimeterX score traffic. Protocol-level tells outside the DOM—JA3/JA4 and related handshake fingerprints—are covered in TLS fingerprinting explained. Headless JS checks sit in the middle: after TLS succeeds, before any mouse telemetry matters.

Signal 1: navigator.webdriver

The WebDriver specification defines navigator.webdriver as a boolean that is true when the user agent is controlled by automation. Real interactive Chrome typically reports false or leaves the property unset in ways detection scripts treat as human. Chrome launched under Playwright, Puppeteer, or Selenium via CDP or ChromeDriver sets it to true.

You can observe the default yourself with a minimal Playwright script pointed at any public endpoint (or at Sannysoft in a headed debug session):

const { chromium } = require("playwright");

(async () => {
  const browser = await chromium.launch({ headless: true });
  const page = await browser.newPage();
  await page.goto("https://httpbin.org/html");

  const webdriver = await page.evaluate(() => navigator.webdriver);
  console.log("navigator.webdriver =", webdriver); // true under stock automation

  await browser.close();
})();

On bot.sannysoft.com this appears as a dedicated WEBDRIVER row. Detection scripts often read the property as early as possible—sometimes before your addInitScript patches run—so the timing of when the flag is set (Blink feature vs late JS overwrite) is part of the story, not just the boolean value.

A launch argument such as --disable-blink-features=AutomationControlled stops Chromium from advertising the controlled state at the engine level. Init scripts that redefine the getter are a second line of defense when the flag still leaks into iframes or workers. Neither change alone makes a session “human”; they only remove the loudest single boolean on these test pages.

Signal 2: Missing or incomplete window.chrome

Desktop Chrome exposes a window.chrome object with nested surfaces (runtime, loadTimes / related helpers depending on version, and app-related stubs). Headless or incomplete automation contexts frequently omit the object or leave it shallow. Public tables often include a CHROME row that fails when the expected structure is absent.

Naive patches that assign window.chrome = {} still fail suites that walk nested properties or check prototype shape. Real Chrome’s object has a specific layout that drifts across major versions, which is why “set chrome once and forget” patches age poorly against CreepJS-style consistency checks even when Sannysoft turns green.

Signal 3: Plugins, MIME types, and languages

Headless Chromium historically reported empty navigator.plugins and navigator.mimeTypes. Interactive Chrome on desktop reports a short, non-zero plugin list (PDF viewer and related entries). Languages may be empty or oddly ordered under automation unless the context is configured with a locale.

Sannysoft-style PLUGINS-LENGTH checks treat length === 0 as a strong automation tell. Privacy-hardened browsers can also shrink plugin lists, so a single empty array is a weak signal in isolation—but combined with webdriver === true and a HeadlessChrome user-agent substring it is decisive on a diagnostic page.

Stealth setups populate plugin and MIME arrays with objects that carry correct prototypes (Plugin, PluginArray), not plain object literals. Detection suites that call instanceof or inspect property descriptors will flag fake arrays that look right in JSON.stringify but wrong under prototype checks.

Signal 4: Permissions API vs Notification defaults

Interactive Chrome and headless Chrome disagree on notification and permission defaults. A common pattern on test pages: query Notification.permission and cross-check navigator.permissions.query({ name: "notifications" }). Headless often reports denied where a fresh human profile would report default, or the two APIs disagree with each other.

CreepJS and similar research tools treat internal inconsistency as a stronger signal than any single value. Patching only Notification.permission while leaving Permissions API answers mismatched will still fail consistency rows. Proper configuration aligns both APIs and matches the expected state for a non-prompted desktop profile.

Signal 5: WebGL vendor and renderer strings

Headless Chrome frequently falls back to a software rasterizer. Public pages print WEBGL VENDOR / WEBGL RENDERER strings; values containing SwiftShader (or other software GL names) stand out against real GPU strings from Intel, NVIDIA, AMD, or Apple.

BrowserLeaks WebGL views make this trivial to compare between a laptop Chrome window and a CI headless job. Anti-bot vendors use the same class of check inside JS challenges: not because SwiftShader is “illegal,” but because it correlates strongly with automated server-side browsers.

Stealth or headful Chromium with GPU access (or with carefully consistent spoofed strings that match the rest of the fingerprint) changes this row. Spoofing a high-end GPU name while canvas and WebGL parameter sets still look like SwiftShader creates the kind of lie that deeper fingerprint tools flag even when Sannysoft’s coarse table passes.

Signal 6: Canvas, fonts, and outer dimensions

Canvas fingerprinting hashes the output of drawing operations. Font enumeration probes which typefaces are installed. Headless environments often share identical canvas hashes across machines and report sparse font sets. Screen and window metrics also diverge: window.outerWidth / outerHeight of 0, or viewport sizes that never appear on real devices, are classic headless tells.

Public font and canvas pages (BrowserLeaks and research demos) are useful because they show the raw values without a commercial scoring UI. The educational point is consistency: user-agent, client hints, screen size, WebGL, and canvas must describe the same machine story. Fixing only the user-agent string while leaving outer dimensions at zero still fails multi-check tables.

Signal 7: CDP and driver artifacts

Chrome DevTools Protocol control leaves traces beyond navigator.webdriver. Selenium/ChromeDriver historically injected identifiable properties ($cdc_, related globals). Runtime side channels—unusual Runtime.enable listeners, iframe contentWindow discrepancies, and console or stack quirks under CDP—appear in research write-ups and in some advanced public demos.

Iframe checks matter because a top-level patch does not always propagate. Suites that create a same-origin iframe and re-read navigator.webdriver or chrome from contentWindow catch patches applied only to the parent page. Context-level init scripts (Playwright browser context, not a single page) exist specifically so child frames inherit the same environment story.

For tool architecture differences that affect how easily these leaks appear—WebDriver HTTP vs CDP WebSocket—see the comparison of Selenium, Puppeteer, and Playwright.

What stock automation exposes vs what stealth changes

Put the table together as a detection surface, not as a patch checklist:

Check (public suites)Stock headless automationConfigured stealth / headful Chromium
navigator.webdrivertrueEngine flag off; property absent/false
window.chromeMissing or shallowPopulated with expected shape
Plugins / MIMEEmptyNon-zero, correct prototypes
Permissions / NotificationInconsistent or always deniedAligned defaults
WebGL rendererSoftware (e.g. SwiftShader)Hardware-like, consistent with canvas
UA / client hintsHeadlessChrome or mismatched hintsAligned Chrome UA + hints
CDP / driver globalsOften presentMinimized or absent
Iframe inheritanceParent patched, child leaksContext-wide init

Passing bot.sannysoft.com with an all-green table means you removed the lazy, well-documented JS markers. It does not mean a commercial bot score will be low. Vendors still weigh residential vs datacenter IP reputation, TLS fingerprints that look like OpenSSL instead of Chrome, header order, cookie maturity, and behavioral entropy. Public pages isolate the browser-environment slice so you can debug it independently.

Using FineData when you do not want to maintain patches

Maintaining Chromium patches, GPU consistency, and init-script timing across Chrome major releases is ongoing work. If you would rather call a managed scrape path than keep a private stealth build current, FineData exposes antibot and stealth flags on the scrape API—see the API documentation. A secondary example (keep your own key out of source control):

curl -X POST https://api.finedata.ai/api/v1/scrape \
  -H "x-api-key: fd_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://bot.sannysoft.com",
    "use_js_render": true,
    "stealth_antibot": true,
    "formats": ["html"],
    "timeout": 60
  }'

Pointing that call at a public diagnostic page is a legitimate way to compare your local Playwright result against a managed stealth profile. The goal is understanding the surface and keeping the browser environment internally consistent—not targeting a third-party product.

Summary

Public headless-detection pages exist to make automation fingerprints visible. Stock Playwright, Puppeteer, and Selenium Chromium fail them for concrete reasons: navigator.webdriver, incomplete chrome objects, empty plugin arrays, mismatched permission defaults, software WebGL, canvas and metric anomalies, and CDP/driver artifacts—including iframe inheritance gaps.

A stealth or headful configuration changes those properties at launch time and via context init scripts so the same public tables score green. That is a technical hygiene win for anyone operating automated browsers, and a teaching tool for reading commercial anti-bot JS challenges. Network and TLS layers remain separate problems; green rows on Sannysoft do not replace them. Use the public suites as regression tests for your browser environment, keep fingerprints internally consistent, and treat vendor names (Cloudflare, DataDome, PerimeterX, Akamai) as classes of systems that consume these signals—not as targets.

#headless-browser #anti-bot #detection #playwright #chrome

Related Articles