# Bot and headless detection

> Fourteen read-only checks that hint at automation or tampering: how to read them, and why they are hints, not verdicts.

Source: https://whiskerjs.com/use-cases/bot-detection/ (last updated 2026-10-26)

> **In plain words:**
>
> Bots are programs that use websites like people do: signing up, logging in, buying stock. Many run in a “headless” browser with no screen. WhiskerJS runs nineteen quick checks for traces such tools leave and lists the ones that fired. A fired check is a hint worth weighing; no fired check does not mean a human.

## The problem

Automated traffic ranges from your own monitoring and tests, through scrapers, to credential stuffing and scripted sign-ups. Simple tools leave obvious traces (`navigator.webdriver` set, a `HeadlessChrome` user agent). Better tools hide them and override browser values to look ordinary, which leaves other traces: functions that are no longer native, values that differ between the page and a fresh frame.

## What WhiskerJS contributes

`result.integrityFlags` is a sorted list of the checks that fired. They run after the collectors, read only, and never write to the page; a check that throws simply does not fire. Two of them compare against a fresh, hidden `about:blank` iframe that is removed at once (see [the clean iframe realm](https://whiskerjs.com/docs/how-it-works/#the-clean-iframe-realm)).

## Signals and flags that matter

| Flag | Fires when (short) | Known false positives |
| --- | --- | --- |
| [`automation_webdriver`](https://whiskerjs.com/docs/integrity/#automation_webdriver) | `navigator.webdriver` is true | your own end-to-end tests, accessibility scanners, monitoring robots |
| [`automation_globals`](https://whiskerjs.com/docs/integrity/#automation_globals) | a global or attribute left by a known automation tool | a page script that happens to use one of the names (rare) |
| [`headless_user_agent`](https://whiskerjs.com/docs/integrity/#headless_user_agent) | `HeadlessChrome`, `PhantomJS` or `SlimerJS` in the user agent | none expected; trivial to hide |
| [`headless_markers`](https://whiskerjs.com/docs/integrity/#headless_markers) | outer window size 0x0, or desktop Chrome UA without `window.chrome` | some embedded Chromium views and webviews |
| [`permissions_inconsistent`](https://whiskerjs.com/docs/integrity/#permissions_inconsistent) | notifications denied while the permissions query says prompt | rare; unusual notification settings |
| [`software_renderer`](https://whiskerjs.com/docs/integrity/#software_renderer) | WebGL names a software renderer such as SwiftShader or llvmpipe | real users without GPU acceleration, remote and virtual desktops |
| [`webdriver_writable`](https://whiskerjs.com/docs/integrity/#webdriver_writable) | `navigator.webdriver` was redefined | none expected |
| [`engine_mismatch`](https://whiskerjs.com/docs/integrity/#engine_mismatch) | engine traits do not match the browser class | user-agent switcher extensions |
| [`iframe_mismatch`](https://whiskerjs.com/docs/integrity/#iframe_mismatch) | page and fresh iframe disagree on navigator, screen or time zone values | top-frame-only extensions |
| [`native_tampering`](https://whiskerjs.com/docs/integrity/#native_tampering), [`prototype_lie`](https://whiskerjs.com/docs/integrity/#prototype_lie), [`navigator_own_property`](https://whiskerjs.com/docs/integrity/#navigator_own_property) | built-in functions or navigator values were replaced | privacy and anti-fingerprinting extensions, some polyfills |
| [`plugins_inconsistent`](https://whiskerjs.com/docs/integrity/#plugins_inconsistent), [`impossible_hardware`](https://whiskerjs.com/docs/integrity/#impossible_hardware) | faked plugin list, impossible CPU or memory values | none expected in current browsers |
| [`exposed_binding`](https://whiskerjs.com/docs/integrity/#exposed_binding) | a window function carries the marker Playwright puts on functions it exposes to the page | none expected |
| [`ua_ch_mismatch`](https://whiskerjs.com/docs/integrity/#ua_ch_mismatch) | the user agent string disagrees with UA Client Hints (version, platform, mobile, a Headless brand) | user-agent switcher extensions |
| [`media_query_mismatch`](https://whiskerjs.com/docs/integrity/#media_query_mismatch) | the CSS device size or pointer disagrees with `screen` and the touch points | rare; a window dragged to another monitor during the collection |
| [`timezone_inconsistent`](https://whiskerjs.com/docs/integrity/#timezone_inconsistent) | Intl and `Date` disagree on the time zone offset | none expected in current engines |
| [`vm_renderer`](https://whiskerjs.com/docs/integrity/#vm_renderer) | WebGL names a virtual machine or emulator graphics adapter | virtual desktops (VDI, Citrix), Parallels on a Mac, developers testing in emulators |

A real example, at signalsVersion 5: Chromium run headless under Playwright in a container returned `automation_webdriver`, `headless_user_agent`, `permissions_inconsistent` and `software_renderer`. Its user agent said `HeadlessChrome` without `window.chrome`, so [`browser_class`](https://whiskerjs.com/signals/browser-class/) was `other`; since signalsVersion 9 such a browser is classed `chromium`. The same setup with WebKit returned `automation_webdriver` and `iframe_mismatch`; that `iframe_mismatch` came from WebKit giving a hidden iframe a 0 by 0 screen, and since signalsVersion 9 the screen values are compared only when the iframe reports a real screen, so stock Safari and WebKit no longer fire it.

## Reading combinations

- `automation_webdriver` together with `automation_globals` is a strong automation sign, per [Using the flags](https://whiskerjs.com/docs/integrity/#using-the-flags).
- `native_tampering`, `prototype_lie` and `navigator_own_property` together point at value overrides: a spoofing script or a privacy extension. Expect them on privacy-minded users.
- `software_renderer` alone is common on real but unusual machines.
- Any single flag alone is weaker than a combination.

## Using structuredId similarity

Bot farms often run many sessions from the same image. Counting distinct accounts per `visitorId`, or per shared `gpu` and `device` parts of the `structuredId`, can show a fleet of identical headless machines. See the grouping example in [Multi-accounting](https://whiskerjs.com/use-cases/multi-accounting/#using-structuredid-similarity). Legitimate fleets (a call centre, a classroom) look similar, so combine this with rate and behaviour.

## Anti-replay

A bot that cannot run the collector well may try to send a recorded result from a real browser. The binding makes that fail: the nonce is single-use and recent, and the digest covers the whole result. See [Anti-replay binding](https://whiskerjs.com/docs/integration/#anti-replay-binding). It does not stop a bot that runs a real browser and collects fresh.

## What must happen on your server

1. Treat flags as features, not a block list. Weigh them with request rate, IP and network reputation, header consistency and behaviour.
2. Separate your own automation (tests, monitoring) by account, network or a header your server controls, not by trusting the flags.
3. Choose graded responses: slow down, challenge, require login, or block, depending on the evidence and the cost of a mistake.
4. Never treat `integrityFlags: []` as proof of a human.

## Limits

- Flags come from code running on a machine you do not control. An attacker can strip them or send a hand-made result.
- Two published techniques are left out on purpose: Chrome DevTools Protocol detection (it needs console output) and a Web Worker cross-check (it needs a `blob:` script that a page policy may block and report over the network). See [Left out on purpose](https://whiskerjs.com/docs/integrity/#left-out-on-purpose).
- No detection or false-positive rates have been measured for WhiskerJS.

## Questions and answers

### Does WhiskerJS say whether a visitor is a bot?

No. It reports which checks fired. Your server weighs them with other evidence such as request rate, network reputation and behaviour.

### Why do my own end-to-end tests show automation_webdriver?

Selenium, Playwright and Puppeteer set navigator.webdriver by default, so their sessions fire the flag. Exclude your test traffic by other means, such as a test account or network.

### No flags fired. Is the visitor human?

Not necessarily. Flags come from the client, so an attacker can remove them, and careful tools leave none. Absence of flags proves nothing.

### Is there a CDP or Web Worker check?

No. Chrome DevTools Protocol detection needs console output and the Worker check needs a blob: script that a page policy may block and report, so both are left out on purpose.

## Related pages

- [Integrity checks](https://whiskerjs.com/docs/integrity/): Every flag, when it fires, false positives.
- [Headless browser](https://whiskerjs.com/glossary/#headless-browser): Glossary entry.
- [webgl_renderer](https://whiskerjs.com/signals/webgl-renderer/): The GPU string behind software_renderer.
- [Account takeover](https://whiskerjs.com/use-cases/account-takeover/): Credential stuffing is mostly automated.
