← dictionary
Detection

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.

Category
Detection
API
navigator.webdriver
Reveals
automation surface
Spoofing
Spoofable, spoof detectable
Since
2017
Liverunning the probe…

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.webdriver to return false moves the property from the prototype onto the instance. The value reads innocent, but Object.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.toString returns for it. If navigator.permissions.query no longer reports [native code], something wrapped it. Tooling can patch toString too — 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" while permissions.query said "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 onto window/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

How the probe works

This is the exact source that ran in the panel above — no summary, no drift.

JavaScript
// 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`,
  };
}