Headless Browser Detection
The JavaScript surface that separates a driven browser from a human-driven one, and why consistency between signals beats any single check.
Published · Updated
- Category
- Detection
- API
navigator.webdriver- Reveals
- automation surface
- Spoofing
- Spoofable, spoof detectable
- Since
- 2017
Automation detection is probability, not proof. No single property proves a session is a script — there is only a pile of weak signals that shift your confidence. The live panel above ran a battery of them and reported how many tripped. On an ordinary browser that count should be zero.
Everything here is publicly documented and trivially spoofable in isolation. The value is not in any one check; it is in the fact that a stealth patch usually fixes one layer and forgets another, and the disagreement is louder than either value alone.
Headless Chrome's declarable surface
The cleanest signal is the one the platform hands you. navigator.webdriver was
standardized precisely so automation is declarable — WebDriver-controlled
browsers set it to true. Honest automation leaves it alone; stealth tooling
redefines it. Alongside it sit the classic tells: a HeadlessChrome token in
the user agent (and its Client Hints counterpart), an empty navigator.plugins, empty navigator.languages, and a
0×0 outer window.
Each of these is one line to fake, which is exactly why the interesting checks look at what faking them costs.
Consistency over presence
The better question is not "is property X present?" but "do the properties agree with each other?" Several checks in the probe target the seams:
- A relocated getter. Redefining
navigator.webdriverto returnfalsemoves the property from the prototype onto the instance. The value reads innocent, butObject.getOwnPropertyDescriptor(navigator, "webdriver")is now non-undefined— the patch left a mark. - Non-native built-ins. Overriding a built-in function in plain JavaScript
changes what
Function.prototype.toStringreturns for it. Ifnavigator.permissions.queryno longer reports[native code], something wrapped it. Tooling can patchtoStringtoo — but then that patch is detectable, and so on down the stack. It is turtles, and each turtle is a signal. - Capability disagreements. Old headless Chrome reported
Notification.permission === "denied"whilepermissions.querysaid"prompt"for the same capability. Two APIs describing one fact differently is the signature of an incomplete environment. - Driver residue. chromedriver historically injected
cdc_-prefixed globals ontowindow/document; their presence is a direct tell.
Rendering signals work the same way: a canvas or
WebGL readout that does not match the platform the
browser claims is a louder tell than any single navigator property, and the
gzip OS byte gives you a platform claim the user
agent cannot edit.
Score, don't gate
Because each signal is weak, the sane output is a score, not a boolean. A hard block on a single heuristic will eventually catch a real user with an unusual configuration — an old browser, an accessibility tool, a locked-down enterprise build — and they will never tell you they were blocked.
The durable guidance is the same as for most defensive engineering: prefer signals that are expensive to fake over signals that are merely obscure, log enough to review your false positives, and never make an irreversible decision from one bit.
References
- W3C WebDriver: the
webdriverflag - MDN:
Navigator.webdriver - Longer tour of the surface: Feature Detection for Headless and Automated Browsers
How the probe works
This is the exact source that ran in the panel above — no summary, no drift.
// Headless / automation detection probe. Each check is weak alone; the
// output is a count, not a conviction. Inconsistency between spoofed values
// is usually louder than any single value.
export async function probe() {
const checks = {};
checks["navigator.webdriver is true"] = navigator.webdriver === true;
checks["'HeadlessChrome' in user agent"] = /HeadlessChrome/i.test(navigator.userAgent);
checks["navigator.plugins is empty"] = navigator.plugins.length === 0;
checks["navigator.languages is empty"] = !navigator.languages || navigator.languages.length === 0;
checks["outer window is 0×0"] = window.outerWidth === 0 || window.outerHeight === 0;
// A stealth patch that redefines the webdriver getter moves it from the
// prototype onto the instance — detectable even when the value reads false.
checks["webdriver getter moved onto navigator"] =
Object.getOwnPropertyDescriptor(navigator, "webdriver") !== undefined;
// chromedriver historically injects cdc_-prefixed globals.
checks["cdc_ driver globals present"] =
Object.keys(window).some((k) => k.startsWith("cdc_")) ||
Object.keys(document).some((k) => k.startsWith("cdc_"));
// Old headless Chrome answered Notification.permission "denied" while
// permissions.query said "prompt" for the same capability.
let permissionMismatch = false;
if (navigator.permissions?.query && typeof Notification !== "undefined") {
try {
const status = await navigator.permissions.query({ name: "notifications" });
permissionMismatch = Notification.permission === "denied" && status.state === "prompt";
} catch {
// query refusing the name is fine; not a signal on its own
}
}
checks["Notification / permissions.query disagree"] = permissionMismatch;
// Overriding a built-in in plain JS changes its toString unless the tooling
// also patches toString — and that patch is detectable in turn.
checks["permissions.query is not native code"] = navigator.permissions?.query
? !Function.prototype.toString.call(navigator.permissions.query).includes("[native code]")
: false;
const hits = Object.entries(checks).filter(([, v]) => v).length;
const total = Object.keys(checks).length;
return {
facts: {
"Automation": hits === 0 ? "no markers" : `${hits} of ${total} markers tripped`,
},
value: {
...checks,
"Checks tripped": `${hits} of ${total}`,
},
label: hits === 0 ? `0 of ${total} checks tripped` : `${hits} of ${total} checks tripped`,
};
}