Skip to content

Headless Chrome detection: every tell from navigator.webdriver to missing codecs

· 21 min read
Copyright: MIT
Wordmark reading headless detection with an orange underline and a list of signal names

A browser that nobody is looking at still has to answer the same JavaScript questions a real one does. What is your user agent? How many plugins do you have? Can you play this H.264 stream? What does your GPU call itself? For most of headless Chrome’s life, the answers came back wrong in small, consistent ways, and a few lines of script on the page could tell that the visitor had no screen, no speakers, and no human moving the mouse. That gap was the whole game. Anti-bot vendors built signal catalogs around it, automation authors patched the signals one by one, and the two sides traded the lead every couple of Chrome releases.

The interesting part is that the catalog never really shrank. It moved. When Google shipped a genuinely new headless mode in 2023, several of the classic tells went quiet, but the underlying question stayed the same: does this environment look like a person’s everyday browser, or like a server pretending to be one? This post is a reference for that question. It walks the individual signals, says where each one comes from, notes which ones the new headless mode closed and which it left open, and is honest about where the public record runs out and inference begins.

The sections below go roughly from cheapest to most expensive to check. First the flags and tokens a server sees without running any script. Then the JavaScript-surface tells: plugins, the permissions contradiction, the window.chrome object. Then the rendering and media signals that need a GPU or a codec that a stripped Chromium build does not have. Then the Chrome DevTools Protocol leaks, which are the most current battleground. A closing section on the old-versus-new split ties it together, because most of what you read predates that split and assumes signals that no longer fire.

The flags that set navigator.webdriver

The single most quoted signal is navigator.webdriver. It is a read-only boolean on the Navigator interface, and it is not a vendor invention. It is defined in the W3C WebDriver specification, which describes it as a standard way for a cooperating user agent to tell the document it is under automation control so that alternate code paths can fire during a test run. The property has been widely available across browsers since May 2018. When it reads true, the browser is admitting that something is driving it.

In Chrome, three launch conditions flip it to true: the --enable-automation flag, the --headless flag, and --remote-debugging-port with port 0. Firefox sets it under marionette.enabled or the --marionette flag. The mechanism is deliberate. WebDriver is a standard, the flag is part of the standard, and a browser that is genuinely being tested is supposed to advertise the fact. That it doubles as a bot tell is a side effect of automation frameworks and scrapers sharing the same plumbing as legitimate test harnesses.

Because it is a deliberate, documented property, it is also the first thing every evasion toolkit removes, usually with --disable-blink-features=AutomationControlled at launch. So on its own the flag tells a detector almost nothing about a determined adversary. Its real value is as a tripwire for lazy automation and as one input among dozens. A visitor with navigator.webdriver === true is trivially a bot; a visitor with it false has merely passed the lowest bar.

launch flag resulting property --enable-automation --headless --remote-debugging-port=0 navigator.webdriver = true Any one of these is enough. Removing the flag is a one-line launch change, which is why the property alone proves little. *The WebDriver flag is documented behavior, not a leak. Three Chrome launch conditions set it; one launch argument hides it again.*

The user-agent token and the headers

Before any script runs, the server already has the request headers, and historically the cheapest tell of all lived there. Old headless Chrome put the literal substring HeadlessChrome in its user-agent string, for example HeadlessChrome/133.0.6943.16, and the same token leaked into the sec-ch-ua client-hint header as "HeadlessChrome";v="133". A server-side regex over the User-Agent header caught the unmodified case without touching JavaScript at all. There is a whole long tail of related header quirks worth its own treatment, covered in the HeadlessChrome user-agent token and the long tail of headless signals.

A second header tell was absence rather than presence. Chromium-based headless browsers shipped without an Accept-Language header by default, where a real browser configured with at least one preferred language always sends one. An empty or missing Accept-Language paired with a Chrome user agent is the kind of internal contradiction that detectors weight heavily, because a genuine Chrome install does not behave that way.

Both of these are trivial to override. Setting the user agent through the automation API and adding the language header takes a couple of lines, and any serious scraper does it. The point of cataloging them is not that they still catch sophisticated traffic. It is that they are free to check and they set the baseline: a request that still carries HeadlessChrome in 2026 is announcing itself, and there is no reason for that to happen except neglect.

The empty surfaces: plugins, mimeTypes, languages

The classic JavaScript-surface tells are all about emptiness. A normal desktop Chrome exposes a non-empty navigator.plugins list and a matching navigator.mimeTypes list, historically including a PDF viewer entry. Old headless Chrome returned an empty navigator.plugins, length zero, and that single fact powered a generation of detection scripts. navigator.languages, which a real browser populates from the user’s language preferences, came back empty in old headless too. A browser claiming to be Chrome with zero plugins and no languages was an easy call.

The open-source detect-headless test suite from Infosimples catalogs these and a few subtler cousins. Beyond the bare length checks, it inspects the prototype chain of the plugins and mimeTypes objects, because a naive override that replaces navigator.plugins with a hand-built array gets the values right but the prototype wrong. navigator.plugins should be a PluginArray, each entry a Plugin; a faked array is a plain Array and the instanceof check fails. This is a recurring theme in runtime fingerprinting: the spoof that fixes the value often breaks the type, and the type is the thing the detector actually reads. The broader catalog of these prototype and descriptor checks is in how anti-bot systems fingerprint the JavaScript runtime.

The same suite lists a cluster of zero-valued environment tells that have nothing to do with plugins but everything to do with running without a window or a human. window.outerWidth and window.outerHeight read 0 in old headless because there is no browser chrome around the viewport. navigator.connection.rtt reads 0. A mousemove event reports movementX and movementY of 0 when the pointer is being moved programmatically rather than by a hand. A broken or default image fails to take on the standard placeholder dimensions a real Chrome assigns. None of these is decisive alone. Together they describe an environment with no surrounding window, no network round-trip measurement, and no physical pointing device, which is a fair description of a server.

The permissions contradiction

The most elegant of the classic tells does not look for a value. It looks for a disagreement between two values that a real browser keeps in sync. The Permissions API lets a page ask navigator.permissions.query({name: 'notifications'}) and get back a state of granted, denied, or prompt. Separately, Notification.permission exposes the same underlying notification permission as a string. In any genuine browser these two agree, because they read the same setting.

Headless Chrome could not manage permission prompts, since there was no UI to show them, and that left the two surfaces out of step. The widely cited test checks for exactly the impossible combination: Notification.permission returning 'denied' while the Permissions API reports the notification permission as 'prompt'. A real browser never lands in that state. One value says the user has refused notifications; the other says the user has not yet been asked. They cannot both be true, and a browser that reports both is not a browser a person is using.

Same underlying notification setting, two read paths Notification.permission "denied" permissions.query(...).state "prompt" contradiction A real browser keeps these in sync. "denied" plus "prompt" is a state a person's browser cannot reach. *The check is for an impossible pair, not a single bad value. Refused and not-yet-asked cannot both hold at once.*

What makes this one durable is that fixing it correctly is harder than it looks. Overriding navigator.permissions.query to echo Notification.permission patches this specific test, but the override itself becomes a new tell if its toString() no longer reads as native code, and the runtime-fingerprinting layer checks for exactly that.

The window.chrome object

A real desktop Chrome carries a window.chrome object with sub-objects like chrome.runtime and, historically, chrome.loadTimes and chrome.csi. Old headless Chrome did not populate it, so the bare presence test, does window.chrome exist, separated headless from headful for a long time. The detect-headless suite lists this as the Chrome Element check, with window.chrome present on headful and absent on old headless.

The deeper checks look past presence to shape. Even when an evasion script defines window.chrome, it has to define the whole nested structure that a genuine install builds, and getting the property descriptors, the enumerability, and the function signatures of the inner objects to match real Chrome across versions is fiddly. A window.chrome that exists but whose chrome.runtime is missing or has the wrong shape is its own inconsistency. The general principle holds here too: the surface is easy to fake at the value level and hard to fake at the structural level, and structural checks are what survive.

Rendering: the WebGL renderer string

Once a detector is willing to spend a little more, it asks the GPU to identify itself. The WEBGL_debug_renderer_info extension exposes two strings, UNMASKED_VENDOR_WEBGL and UNMASKED_RENDERER_WEBGL, that name the graphics vendor and the renderer. On a real machine with a real GPU these carry hardware-specific detail. A current Chrome on a discrete NVIDIA card returns something like ANGLE (NVIDIA, NVIDIA GeForce RTX 3090 (0x00002204) Direct3D11 vs_5_0 ps_5_0, D3D11). On an Apple laptop it reads ANGLE (Apple, ANGLE Metal Renderer: Apple M1 Pro, Unspecified Version). On Intel integrated graphics it names the specific UHD part. The string includes device IDs, model numbers, and a driver path.

Headless Chrome on a server usually has no GPU to ask, so it falls back to software rendering, and the fallback names itself plainly. SwiftShader, Google’s software rasterizer, shows up as Google SwiftShader or, through ANGLE, as the unmistakable ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver). The Mesa software path reports llvmpipe. None of these names a physical GPU, and that is the tell. A visitor whose user agent claims a consumer Windows laptop but whose WebGL renderer says SwiftShader is describing a machine that does not exist in a living room.

UNMASKED_RENDERER_WEBGL real GPU, hardware-specific ANGLE (NVIDIA, GeForce RTX 3090 ... D3D11) ANGLE (Apple, Metal Renderer: Apple M1 Pro ...) ANGLE (Intel, Intel(R) UHD Graphics 630 ...) software fallback, no physical GPU Google SwiftShader ANGLE (Google, ... SwiftShader Device ... ) *The hardware strings name a specific card and driver. The software strings name a renderer library and nothing else. The mismatch with a consumer user agent is the signal.*

The renderer string is also a place where spoofing creates fresh contradictions, because the WebGL fingerprint is more than two strings. The supported-extension list, the precision formats, and the actual pixels drawn by a shader all vary with the renderer. A bot that hardcodes a plausible NVIDIA string but still rasterizes through SwiftShader will produce a canvas hash that does not match any real NVIDIA card, and a detector that draws and reads back a known scene catches the gap. Castle’s write-up on this gives the canonical example of the mismatch: a user agent claiming Android while the renderer reports Apple GPU. The role of the renderer string in the wider fingerprint is detailed in how anti-bot systems fingerprint the JavaScript runtime.

Media: the codecs Chromium was never licensed to ship

The most structural tell of all is one a bot cannot easily fake from JavaScript, because it reflects how the binary was built. Google Chrome ships with proprietary media codecs that Chromium, the open-source base, does not. The split exists because those codecs carry patent-licensing obligations that Google pays for in its branded product and that an unbranded Chromium build cannot assume. Per the Chromium project’s own audio-video documentation, the codecs limited to Google Chrome include H.264 / AVC, H.265 / HEVC, AAC in its Main, LC and HE profiles, and xHE-AAC. A default Chromium build is compiled without them.

The build flags that draw the line are proprietary_codecs and ffmpeg_branding. The ffmpeg_branding value chooses which FFmpeg variant links in; Chrome pulls in the additional proprietary codecs including MP3, while Chromium builds only the default open set. The proprietary_codecs flag, off by default, controls what the build advertises to web content through <source> elements and canPlayType(). So a page can simply ask. Calling video.canPlayType('video/mp4; codecs="avc1.42E01E"') or MediaSource.isTypeSupported(...) for an H.264 or AAC type returns a non-empty support string on real Chrome and an empty string on a stock Chromium build. A browser that presents itself as Chrome but reports no H.264 support is running on a Chromium binary that was never branded as Chrome.

This signal is sturdy for a reason the others are not. The plugin list, the permissions state, the renderer string, all live in JavaScript-visible properties that an injected script can rewrite before the detector reads them. Codec support is a property of the compiled media stack. Faking it in the obvious places is possible, but canPlayType returning yes while an actual decode of an H.264 frame fails is another readback contradiction, and the decode is much harder to fake than the string. The practical lesson for automation authors has long been to run a Chrome build with the proprietary codecs included rather than a bare Chromium, precisely because the codec answers are baked in at compile time.

It is worth being precise about what this does and does not prove. The codec gap distinguishes a stock Chromium build from a Chrome build. It does not, by itself, distinguish headless from headful, because a headless Chrome built with the proprietary codecs answers the codec questions correctly. The signal is really about which binary you are talking to, and it became a headless tell historically because so much automation ran on plain Chromium rather than on branded Chrome.

The CDP layer: how the automation protocol leaks

The most current battleground is not a property at all. It is the side effects of the protocol the automation framework speaks. Puppeteer, Playwright, Selenium, and the rest drive Chromium through the Chrome DevTools Protocol, and CDP leaves traces in the page’s own JavaScript environment when certain domains are enabled.

The signal that dominated 2023 through early 2025 exploited the Runtime domain. Automation libraries call Runtime.enable so they can receive events from the runtime, and once that domain is on, Chrome emits a Runtime.consoleAPICalled event whenever the page calls a console method. Serializing the arguments of that event means Chrome reads the objects being logged. The detection trick was to log an Error object whose stack property was a getter, something like defining stack on an error with a getter that flips a flag when read. In a normal browser, nothing reads that getter at log time. Under CDP with Runtime.enable active, the serialization path reads .stack, the getter fires, and the flag flips. A few lines of script on the page learn that the visitor is driven by CDP, even when navigator.webdriver is gone and the user agent is clean. DataDome’s research team published a clear account of this signal in mid-2024, and it was an open secret among anti-bot vendors for a couple of years before that.

The Error.stack getter signal, before and after May 2025 Runtime.enable (automation on) console.debug(err) CDP serializes args .stack getter fires, flag set After two V8 commits in May 2025: error preview skips user-defined getters (valid ScriptId) the getter never runs, the flag never sets, the signal goes dark Detectors that depended on it lost it quietly. The CDP connection itself is still observable by other means. *A side effect, not a property. The getter only ran because CDP serialized the logged error, and a V8 change in 2025 stopped that serialization from running user code.*

That signal quietly stopped working in 2025. Two V8 commits, one on 7 May 2025 and one on 9 May 2025, changed how DevTools previews error objects so that user-defined getters are no longer invoked during the preview. V8 added a guard that checks whether a property getter is user code by looking for a valid ScriptId, and skips it if so. The whole trick depended on a user getter running as a side effect of DevTools inspecting the error, and once that side effect was gone the flag never flipped. Castle’s analysis traced the commits and noted the irony that a privacy-and-safety hardening in V8 broke a detection technique that the same vendors relied on, and that it landed without much public notice. The Chrome stable version where these commits shipped is not stated in that write-up, so the precise version boundary is something to verify against the V8 release notes rather than assert.

Losing one observable does not close the CDP front. The connection has other consequences. Some frameworks expose named bindings on the page, for example Playwright historically left properties like __playwright__binding__ or __pwInitScripts reachable, and exposed-function stringification can reveal framework-specific internals. And the broader fact that CDP requires serializing data between the browser and the controller creates a class of timing and behavioral differences that do not depend on any single getter. The detail of how a specific vendor weaponizes the protocol against patched runtimes is covered in Kasada’s anti-instrumentation work on detecting CDP, Playwright, and patched runtimes. The headline is that the CDP layer is the live edge of headless detection, and it moves on the order of single Chrome releases.

Old headless versus —headless=new

Most of what is written about headless detection, including a fair amount above, describes old headless Chrome, and that mode no longer exists in current Chrome. Understanding the split is the difference between a current catalog and a stale one.

Old headless was a separate browser implementation. It shared a lot of code with Chrome but reimplemented the parts that assumed a window and a screen, and that parallel implementation is where the tells came from. It announced HeadlessChrome in the user agent, returned an empty navigator.plugins, lacked a populated window.chrome, and carried the permissions contradiction, all because the reimplementation skipped or stubbed the relevant machinery. A new headless mode landed in Chrome 112 in early 2023, initially behind --headless=new, and it is not a reimplementation. As one of the Chrome engineers put it on Hacker News, the old headless was merely pretending to be a Chromium browser while the new headless is a Chromium browser, running the same binary with the window simply not drawn.

The fingerprint consequences were immediate. New headless dropped the HeadlessChrome user-agent token and presents as ordinary Chrome. It populates window.chrome. It reports the same plugins as headed Chrome. Antoine Vastel’s much-discussed February 2023 analysis described the new mode’s fingerprint as near-perfect, meaning the cheap property-level tells that had worked for years went quiet in a single release. Google later split out a separate chrome-headless-shell binary in 2023 for callers who genuinely wanted the old lightweight behavior, and removed the --headless=old flag entirely in Chrome 132, after which passing it prints an error instead of launching anything.

Old headless vs --headless=new signal old headless --headless=new HeadlessChrome UA token present gone navigator.plugins empty populated window.chrome absent present permissions contradiction yes largely closed software WebGL / no GPU common depends on host CDP side effects yes yes *New headless closed the cheap property tells. The signals that survive it, software rendering and CDP side effects, are properties of the host and the driving protocol, not of the headless mode itself.*

What new headless did not change is the part that was never about the rendering mode. The codec gap still distinguishes a Chromium build from a Chrome build whether the window is drawn or not. The WebGL renderer still names SwiftShader when there is no GPU, which on a headless server is the common case regardless of --headless=new. The CDP side effects still fire because they come from the automation protocol, not from headlessness. So the catalog did not collapse when new headless arrived. It shed the cheapest, most JavaScript-superficial entries and left the ones rooted in the host environment and the control channel, which are exactly the ones that cost more to fake.

What the catalog tells you

The throughline across every signal here is that the durable tells are the structural ones. A value that lives in a JavaScript property can be rewritten by a script that runs before the detector reads it, and the entire history of navigator.webdriver, the plugin list, and the user-agent token is a history of values being rewritten and detectors moving on. The signals that aged well are the ones where faking the value leaves a second-order contradiction: a plugins array with the wrong prototype, a permissions surface that disagrees with itself, a renderer string that does not match the pixels a shader actually draws, a canPlayType answer that the decoder cannot back up, a console getter that only fires because CDP is serializing the log. In each case the cheap lie is easy and the consistent lie is expensive.

That is also why the new headless mode mattered less than the headlines suggested. It made the cheap lies unnecessary by telling the truth at the property level, which retired a batch of property checks. It did nothing for the structural signals, because those were never about whether a window was drawn. A bot author who switches to new headless still has to supply a real GPU or accept the SwiftShader tell, still has to run a Chrome-branded build or fail the codec questions, and still has to hide the CDP connection or trip whatever the current side-effect signal happens to be. The version-to-version churn in that last category is the real state of the art, and the May 2025 V8 change that silently retired the Error.stack getter is a fair illustration of how it moves: not through a public arms-race announcement, but through a runtime change made for unrelated reasons that flips a long-relied-upon signal off between one Chrome build and the next.


Sources & further reading

Frequently asked questions

Why isn't navigator.webdriver enough on its own to detect a bot?

navigator.webdriver is a documented W3C property that Chrome sets true under flags like --enable-automation, --headless, or --remote-debugging-port=0, so unmodified automation announces itself. But it is the first thing evasion toolkits remove, usually with --disable-blink-features=AutomationControlled at launch, which is a one-line change. A visitor reporting true is trivially a bot, while one reporting false has only passed the lowest bar and reveals nothing about a determined adversary.

How can a page tell headless Chrome from its notification permissions?

The check looks for a contradiction between two read paths to the same notification setting. The Permissions API returns granted, denied, or prompt, while Notification.permission exposes the same setting as a string, and in a real browser they agree. Old headless Chrome could not show permission prompts, so it could report Notification.permission as denied while the Permissions API reported prompt. That pair, refused and not-yet-asked at once, is a state a person's browser cannot reach.

Why does a software WebGL renderer string expose a browser running on a server?

The WEBGL_debug_renderer_info extension exposes vendor and renderer strings that, on real hardware, name a specific GPU, device IDs, and driver path. A headless browser on a server usually has no GPU and falls back to software rendering, which names itself plainly as Google SwiftShader, an ANGLE SwiftShader string, or llvmpipe for Mesa. A user agent claiming a consumer laptop while the renderer reports SwiftShader describes a machine that does not exist, and hardcoding a fake string still leaves canvas pixels that do not match the claimed card.

What does a missing H.264 codec reveal about a browser claiming to be Chrome?

Google Chrome ships proprietary codecs that the open-source Chromium base does not, because they carry patent-licensing obligations Google pays for only in its branded product. These include H.264, H.265, AAC profiles, and xHE-AAC, gated by the proprietary_codecs and ffmpeg_branding build flags. A page can ask via canPlayType or MediaSource.isTypeSupported, and a build presenting as Chrome that reports no H.264 support is running a stock Chromium binary. This identifies which binary is in use rather than whether the window is drawn.

How did anti-bot vendors use the CDP Runtime.enable console getter, and why did it stop working?

Automation frameworks drive Chromium through the Chrome DevTools Protocol and call Runtime.enable, after which Chrome emits a consoleAPICalled event and serializes logged objects. Detectors logged an Error whose stack property was a getter; under CDP the serialization read that getter and flipped a flag, revealing a driven visitor even with a clean user agent. Two V8 commits in May 2025 changed how DevTools previews errors so user-defined getters, identified by a valid ScriptId, are skipped, so the getter never fires and the signal went dark.

Further reading