How sites decide you are a bot
Tiếng Anh
Headless browsers and automation scripts leave traces in navigator, the window object, the DevTools protocol and the network layer. This explains where each signal comes from, why none is reliable on its own, and why ordinary humans get flagged too.
Đọc 6 phút · Đăng 10 tháng 9, 2026
You open a site, you have not clicked anything yet, and the spinning Cloudflare box has already decided to let you through. It went on a pile of small signals, each unreliable by itself, weighted together into a judgement that is good enough. Our bot detection card uses the steadiest handful from the same pile.
Three verdicts
Risk systems usually sort visitors into three buckets. Search-engine crawlers (Googlebot, Bingbot, Baiduspider) and uptime monitors are "good bots": they announce themselves openly, reverse DNS confirms who they are, and most sites welcome them. Scripts driven by Selenium, Puppeteer or Playwright, plus crawlers impersonating search engines, are "bad bots", the ones doing credential stuffing, fake signups, coupon abuse and scraping. Everyone else is "probably human".
Note the word probably. No signal proves there is a person in the chair. The most a system can say is that this environment does not look like it is under script control.
navigator.webdriver
The bluntest signal. The W3C WebDriver spec requires that navigator.webdriver be true whenever the browser is under automation. Browsers launched by Selenium, Playwright and Puppeteer satisfy that by default, so the check is one line of code.
Naturally, the first thing any scraper author does is remove it. Stealth plugins override the property with Object.defineProperty or disable it via launch flags. But a sloppy removal leaves new traces. Object.getOwnPropertyDescriptor(navigator, 'webdriver') should return undefined because the property lives on the prototype; after an override it returns a descriptor. A rewritten getter no longer stringifies to function get webdriver() { [native code] }. The check shifts from "is the flag set" to "has someone touched the flag".
Chrome things that went missing
A normal desktop Chrome almost always has a few things: a window.chrome object (with runtime, loadTimes and friends), three to five built-in entries in navigator.plugins (PDF Viewer, Chromium PDF Plugin and so on) and a non-empty navigator.languages.
Early headless Chrome lacked window.chrome, had an empty plugin list and sometimes an empty language list. Its UA literally said HeadlessChrome; PhantomJS before it announced its own name. These were the first detection tricks and the first to be patched. The newer headless mode (--headless=new) is now close to indistinguishable from headed Chrome.
The plugin count needs care, though. Mobile Chrome has zero plugins by design, and Firefox stopped exposing a plugin list long ago. So "zero plugins" is only a signal under the premise "claims to be desktop Chrome"; otherwise it is pure false positive. That is why it costs only five points in our authenticity score.
Windows and permissions that contradict themselves
A headless environment has no real window, so window.outerWidth and outerHeight are often 0, or their relationship to innerWidth/innerHeight is nonsensical, such as the content area being larger than the frame, or screen.availWidth exceeding screen.width. A browser a human is looking at does not do this.
Another classic contradiction involves notification permission: Notification.permission returns denied while navigator.permissions.query({name: 'notifications'}) says prompt. In a normal browser both APIs agree; they only disagree in certain headless configurations.
CDP traces
Puppeteer and Playwright drive the browser through the Chrome DevTools Protocol, and once the protocol is attached it has side effects. The best known: when DevTools serialises an Error object it reads its stack property. A detection script can create an Error, install a getter on stack, and pass the object to console.debug(). Without DevTools the getter never fires; with CDP attached it does. Similarly, Runtime.enable leaves detectable changes to the page's execution contexts.
Clever as these are, they are also the biggest source of false positives: press F12 to open DevTools yourself and you trigger them just the same. So we use only the least error-prone of them and label the result as indicative. The other side keeps up too: newer Puppeteer releases and various patches avoid Runtime.enable, and the contest continues.
Behaviour and network: signals beyond the script
Everything so far is a static property of the JS environment. The heavy lifting in real risk systems happens in two other places.
Behaviour. Human mouse paths curve and jitter, hover over things, hesitate. Scripted paths go from A to B in a straight line, and click intervals are as regular as a metronome. Typing rhythm, scroll acceleration and dwell time all get compared against what people actually do.
Network. An "ordinary user" arriving from a data-centre IP (AWS, Google Cloud, any VPS provider) is inherently odd; see the IP quality article. Harsher still is TLS fingerprinting. Python's requests library negotiates TLS with a cipher-suite order and extension list (its JA3/JA4 fingerprint) that look nothing like Chrome's. Pair that with a Chrome UA and the disguise fails at the TLS layer before any JavaScript runs.
Putting it together
Every signal above has counterexamples. webdriver can be removed, plugin counts faked, mouse paths jittered, data-centre IPs swapped for residential proxies. So Turnstile, reCAPTCHA v3 and the commercial risk SDKs do not evaluate rules one at a time. They weight each signal, check whether signals corroborate each other, feed the lot into models trained on large volumes of traffic, and emit a score. High scores pass silently, middling ones get a challenge (a spinner, a picture grid), low ones are blocked.
It is also why "bypassing detection" never ends. Fix one signal and the weight on the others goes up.
False positives: humans mistaken for bots
Seen from the other side, a few groups of real people get wrongly flagged with some regularity. People running privacy extensions, first of all: those extensions alter navigator properties, block canvas and empty the plugin list, and the alterations themselves look like a disguise. Corporate machines, where group policy disables plugins, locks the language and standardises the virtual environment, so a whole fleet shares one fingerprint. Old or niche browsers and Linux desktops, simply because there are few samples and the models are less sure. And developers debugging with DevTools open, for whom every CDP-style check fires.
That is why our card does not reduce the answer to yes/no. It lists which signals fired. Seeing the list tells you which extension or setting is responsible, instead of leaving you staring at a red cross.
FAQ
I do not write scrapers. Why should I care? Because false positives land on you. A privacy extension, a VPN exit and a Linux desktop stacked together are enough to make a fair number of sites throw CAPTCHAs at you constantly. Knowing which signal fired is what lets you adjust something specific.
Is patching navigator.webdriver enough? No, and a sloppy patch adds a "tampered" signal on top. Modern detection scores overall consistency, not any single flag.
Does this site's bot check send my data anywhere? No. All checks run locally in your browser. Only if you opt into the statistics do we upload attribute hashes, never raw values.