← index

Feature Detection for Headless and Automated Browsers

bot-detectionautomationjavascript

Demo post. This is a placeholder written to exercise the publishing pipeline. The checks below are well-known, publicly documented examples used to illustrate the shape of the problem — not novel research.

Automation detection is an exercise in probability. There is no single property that proves a session is driven by a script, only a pile of weak signals that, taken together, shift your confidence. Anything you write here will be read, understood, and patched by someone on the other side.

The obvious surface

The classic starting point is navigator.webdriver, standardized precisely so that automation is declarable rather than guessable.

JavaScript
const surfaceChecks = {
  webdriver: navigator.webdriver === true,
  noLanguages: !navigator.languages || navigator.languages.length === 0,
  noPlugins: navigator.plugins.length === 0,
  headlessUa: /HeadlessChrome/i.test(navigator.userAgent),
  zeroDimensions: window.outerWidth === 0 || window.outerHeight === 0,
};

Every one of these is trivially spoofable — navigator.webdriver can be redefined, and the user-agent string is a suggestion, not a fact. They are worth collecting anyway, because inconsistency between spoofed values is often louder than the values themselves.

Consistency over presence

The more interesting question is not "is property X present?" but "do the properties agree with each other?" A patched environment usually gets one layer right and forgets another.

TypeScript
type Signal = { name: string; suspicious: boolean };

function consistencySignals(): Signal[] {
  const uaMobile = /Mobi|Android/i.test(navigator.userAgent);
  const touch = navigator.maxTouchPoints > 0;

  return [
    {
      name: "mobile-ua-without-touch",
      suspicious: uaMobile && !touch,
    },
    {
      name: "patched-webdriver-getter",
      suspicious: (() => {
        const descriptor = Object.getOwnPropertyDescriptor(navigator, "webdriver");
        return descriptor !== undefined;
      })(),
    },
    {
      name: "native-code-mismatch",
      suspicious: !Function.prototype.toString
        .call(navigator.permissions.query)
        .includes("[native code]"),
    },
  ];
}

That last check is a useful shape to internalize. Overriding a built-in function in plain JavaScript changes what Function.prototype.toString returns, so an override leaves a fingerprint unless the tooling also patches toString — and then that patch is detectable, and so on down the stack.

Scoring, not gating

Because each signal is weak, the sane output is a score rather than a boolean.

JavaScript
function score(signals) {
  const hits = signals.filter((s) => s.suspicious).length;
  return hits / signals.length;
}

Treat the score as an input to a decision, not the decision itself. A hard block on a single heuristic will eventually catch a real user with an unusual configuration, and they will never tell you.

Where this goes

Detection and evasion are adversarial and iterative. The practical takeaways are the same ones that apply to 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.