How this actually works
Detection is not a blocklist. It is a consistency argument. A real browser on a real machine reports one coherent story about itself, because every value comes from the same computer. Everything below is a way of asking the same question from a different angle and checking that the answers still agree.
Five independent layers
No single check decides anything. Each contributes a weight, the weights are summed, and the total passes through a saturating curve so one decisive signal lands high while a pile of weak ones cannot run away.
1. Is it driven by software?
WebDriver sets navigator.webdriver. ChromeDriver leaves properties
beginning cdc_ on the document. Selenium, Puppeteer, Playwright,
Cypress and PhantomJS each leave named globals behind. An attached DevTools
Protocol session reveals itself when an inspector serialises an object and reads
its stack.
2. Is there a real browser window?
Headless Chrome has no toolbar, so the viewport exactly fills the window. It has no GPU, so WebGL falls back to SwiftShader. It has no fonts, no speech voices and no audio or video devices. The Chromium that Puppeteer downloads is built without the licensed H.264, AAC and MP3 decoders that every retail Chrome ships with.
3. Has anything been rewritten?
Native functions stringify as [native code]; a JavaScript
replacement does not. Prototype accessors throw when applied to a foreign object;
a hand-written getter returns a value instead. A fresh iframe gets a clean realm,
so patches applied only to the main window do not follow it.
4. Does the identity hold together?
Fonts, GPU driver strings, installed voices, the platform string and Client Hints are read from five unrelated subsystems. A spoofing tool has to keep all of them in sync. In practice it manages two or three.
5. Does a person seem to be there?
Human pointer movement curves, accelerates and arrives on an irregular clock. Synthetic input travels in straight lines at constant speed on a fixed timer. Key presses have variable hold times; scripted ones are identical to the millisecond.
And: where is it really coming from?
The address on the HTTP request, the address WebRTC discovers over UDP, and the clock on the machine. A proxy changes the first, cannot change the second, and never touches the third.
The strongest single signals
Missing licensed codecs
Chrome ships with paid licences for H.264, AAC and MP3. The open-source Chromium build that Puppeteer and Playwright download by default does not. A browser claiming to be Chrome that cannot play an MP3 is not the Chrome it says it is. This one check catches a large share of naive scraping infrastructure and costs nothing to run.
The WebRTC address comparison
To connect two peers, WebRTC asks a STUN server what address the packets appear to come from. That probe goes out over UDP, straight from the machine. An HTTP proxy never sees it. A VPN browser extension that rewrites web requests cannot intercept it. So when the address WebRTC reports differs from the address our socket accepted, the web traffic is being proxied and the machine behind it has just identified itself.
Chrome and Edge replace local network addresses with random
.local names. That is a real protection and it is unrelated: it does not
cover the public address discovered through STUN.
Test your own connection.
Timezone coherence
A browser reads its timezone name, its current UTC offset and its daylight saving behaviour from one system database. They cannot disagree. Spoofing tools routinely set the name without moving the clock, or move the clock without fixing the seasonal behaviour. We recompute all three from the IANA database and check them against each other before comparing any of them with the network location.
Engine version drift
Chromium ships features on a published schedule, so the APIs actually present pin down
the real engine version. A User-Agent claiming Chrome 130 on an engine with no
:has() support is claiming to be twenty-five versions newer than it is.
The reverse is just as telling: a browser cannot have features from the future.
This check abstains when the feature set is patchy rather than simply old, because enterprise policy, origin trials and privacy forks all legitimately remove individual APIs. Accusing on a contradictory reading would punish real people.
Coordinated behaviour across visitors
Some patterns only appear once you look at more than one request. One device fingerprint arriving from twelve different networks in a day is a rotating proxy pool: the machine stays the same while the exit node moves. One address running forty distinct fingerprints is an emulator farm, or randomised anti-fingerprinting. Neither is visible from inside a single page view.
What gets caught, and by what
Automation is not one thing. These are the tools people actually use, ordered by how hard each is to see, with the signal that decides it. Every row is exercised as a simulated machine in the test suite before a release, so this table is a description of what the engine does rather than a claim about what it might do.
| What is driving the browser | Caught by | Verdict |
|---|---|---|
| Headless Chrome / Chromium Puppeteer or Playwright with the bundled browser, out of the box |
The User-Agent says HeadlessChrome. Even with that rewritten: no licensed H.264, AAC or MP3, because the bundled build is unlicensed; SwiftShader instead of a GPU; three fonts; an empty plugin registry; an 800×600 window. |
certain |
| Selenium / ChromeDriver stock, headed or headless |
navigator.webdriver is true, and ChromeDriver leaves cdc_ variables on window and document. Either alone settles it. |
certain |
| Stealth-patched automation puppeteer-extra-plugin-stealth, undetected-chromedriver, anti-detect browsers |
The patches are installed on the page's window. A Worker is a separate realm the browser builds itself, so it still reports the real User-Agent, core count, memory and GPU. Six independent properties have to be kept in sync across two realms, and they are not. |
high |
JavaScript automationelement.click(), synthesised events, Selenium's execute_script |
isTrusted is false — only JavaScript can produce that. The event also carries a detail count of zero, coordinates at the origin, and no button press in front of it. One click is enough. |
certain |
CDP-driven inputpage.mouse.click, locator.click, Selenium's own click |
These travel the browser's real input pipeline, so isTrusted is true and useless. What gives them away is the contents: Input.dispatchMouseEvent is handed one x,y pair and puts it in both the screen and viewport coordinates, which a window with a title bar cannot do; and the pointer moves without ever reporting a movement delta. |
high |
| An inspector attached any CDP client, or DevTools left open |
With the Runtime domain enabled, anything passed to a console method is serialised into a preview for the inspector, and building that preview runs the object's accessors. Nothing else reads them. | ambiguous |
| A real Chrome driven over CDP, doing nothing headed retail Chrome on real hardware, started with --remote-debugging-port |
Only the inspector probe. Nothing is spoofed, so nothing can contradict itself — it genuinely is a real browser on a real machine. It scores suspicious, not automated, and stays there until it touches something. | partial |
Does it matter how the page was opened?
No. Navigating with window.location instead of driver.get(),
or handing the address to Chrome on its command line, changes three facts: there is no
referrer, there is one history entry, and no user gesture has happened yet. A person
typing an address into a fresh tab produces all three, so none of them is scored.
None of it changes anything that matters, because every signal above reads the
runtime rather than the route in. A driver that arrives by
window.location still has its webdriver flag, still leaves its
cdc_ variables, still disagrees with its own worker, still has an inspector
attached, and still has to synthesise every click it makes. Changing the door does not
change what walks through it.
What this cannot do
Trusted input is still trusted. Mouse and keyboard events driven
through the DevTools Protocol carry isTrusted: true, exactly like a real
hand. They are caught on their contents and on path geometry instead — but an operator
who replays recorded human movement, with correct screen offsets and movement deltas,
defeats that layer. It raises the cost; it does not close the door.
A real browser sitting still is nearly invisible. A headed retail Chrome on real hardware, driven over CDP, that only reads pages and never clicks, contradicts nothing — because nothing about it is false. The inspector probe is the only thing separating it from a person, and that same probe fires for a developer with DevTools open. It is reported as instrumentation, never as automation on its own.
A determined operator can pass. A commercial anti-detect browser on a residential proxy, driven by an actual person, is a genuine browser on a genuine connection. The contradictions get subtler, not absent, which is why the score is a gradient rather than a verdict.
Some honest people look suspicious. Privacy browsers randomise canvas output. Corporate networks egress through datacenters. Travellers carry their home timezone. Treat a high score as a reason to ask for more proof, not as proof by itself.
Using it responsibly
- Log before you block. Watch a week of real traffic and find where your genuine customers sit.
- Challenge rather than refuse, wherever a challenge is possible.
- Fail open. If the check is unavailable, do not lock out paying customers.
- Never expose the reasons to the visitor. It tells the wrong people exactly what to fix.
- Verify server-side. A verdict that arrives from the browser can be rewritten by whoever controls the browser.
- Say what you collect in your privacy notice. Fraud prevention is a legitimate purpose, but it is not an exemption from disclosure.