Most articles on this subject are a list of Chromium flags. Paste them into your launch config, the story goes, and the wall comes down. That advice had a shelf life of maybe eighteen months, back when navigator.webdriver really was the whole game. It is not the whole game now, and it hasn't been for a long time.
The uncomfortable truth is that by the time a detection script runs a single line of JavaScript, it has often already made up its mind. Your TLS handshake arrived before the page did. Your IP was scored before your headers were parsed. The edge knew your request came from a Hetzner range and that the same range had asked for 400 product pages in the last minute. Nothing you do inside the page fixes that.
So this guide is organised the way detection actually works: as a stack of layers, from the wire up to the human. For each layer I'll describe what is being measured, which browser automation detection bypass methods actually address it, and — more usefully — where each method breaks. Some of it is bad news. Flag lists are cargo cult. Stealth plugins that monkey-patch navigator are detectable by a five-line cross-realm check. Captcha solvers treat a symptom and hide the disease.
The good news is that the layers that matter most are the ones you have the most control over, and they don't require exotic tricks. They require a real browser, a real identity per worker, an exit IP that agrees with that identity, and pacing that doesn't look like a for loop.
Detection is four layers, not one check
It helps enormously to stop thinking about anti-bot systems as a single yes/no gate and start thinking about them as a scoring pipeline with four independent inputs. Each layer collects evidence. The evidence is combined, weighted by how much the site cares, and turned into an action: serve the page, serve a challenge, serve a soft block, or ban the account.
Layer 1 — the transport
Before any HTML moves, your client negotiates TLS and (usually) HTTP/2. Both handshakes are surprisingly expressive. The exact list and ordering of cipher suites, the extensions you advertise, whether you emit GREASE values, your ALPN string, your HTTP/2 SETTINGS values, the order of your pseudo-headers — all of it forms a stable signature. This is what JA3 and its successor JA4 summarise into a hash.
The practical consequence: a Python script using requests with a Chrome User-Agent header is trivially caught, because the header claims Chrome 140 while the handshake claims OpenSSL. No amount of header spoofing fixes a mismatch one layer down.
Layer 2 — the runtime
This is the layer everyone writes about: the JavaScript object graph inside the page. Is navigator.webdriver true? Does window.chrome exist and have the right shape? Are there leftover automation globals? Does a function that should be native print [native code] when stringified? Is a debugger attached and listening?
Layer 3 — the identity
Everything that says which device and which person this is: canvas and WebGL rendering output, audio processing quirks, installed fonts, screen geometry, hardware concurrency, timezone, language, and — critically — the cookies and local storage that prove you have been here before. If you're new to this half, our beginner's explainer on browser fingerprinting covers the mechanics without assuming you write C++.
Layer 4 — the behaviour
How the session unfolds over time. Request velocity, navigation order, whether the mouse moved before the click, whether the click landed on the exact geometric centre of the button, dwell time, scroll depth, and how many pages you touched that a human would never visit in that sequence.
A solid setup addresses all four. Most failing setups have three good layers and one catastrophic one — usually layer 1 or layer 4 — and the operator spends weeks tuning layer 2 because that's where the blog posts are.
The automation tells that actually get you caught
Let's be specific. These are the checks I have seen in production detection bundles, roughly in order of how commonly they appear.
navigator.webdriver and the automation handshake
When Chromium is launched with --enable-automation, or when a WebDriver session attaches, the browser sets navigator.webdriver to true and shows the yellow infobar. That is by design — it's a spec-level honesty flag. Suppressing it with --disable-blink-features=AutomationControlled was the standard move for years, but the flag has a cost of its own: it changes feature-detection behaviour in ways that can be observed, and on some Chromium builds it has caused new-tab instability. Not launching with automation switches on at all is a cleaner answer than switching them on and then hiding them.
Runtime.enable is the loudest thing in your stack
This one deserves its own paragraph because it is the single most under-discussed item in the whole topic. The Chrome DevTools Protocol has a Runtime domain. Enabling it — which Puppeteer and Playwright do more or less immediately on attach, because their whole evaluation model depends on it — changes observable page behaviour. The classic detection is a getter trap: create an object with a getter on a property, console.log it, and see whether the getter fires. With no console consumer, it doesn't. With Runtime.enable active, the CDP client serialises the object for Runtime.consoleAPICalled and the getter runs. Set a flag, and you have detected an attached debugger without inspecting a single navigator property.
There are variants: reading .stack on a freshly constructed Error, timing differences in debugger statements, and the presence of extra frames in stack traces from injected code. The defence is architectural, not cosmetic — drive the browser over CDP without ever enabling the Runtime domain. Everything you need for scraping is available in DOM, Input, Page, Network and Target. You resolve a node, you dispatch a real input event to its coordinates, you read the DOM back. It is more work to write than page.evaluate(), and it is why our own automation layer treats Runtime.enable as an opt-in exception that gets flagged in the response rather than a default.
Injected scripts leave fingerprints of their own
Page.addScriptToEvaluateOnNewDocument is the mechanism behind essentially every stealth plugin. It runs your patch before page scripts. The problem is that the patch itself is evidence.
A JavaScript override of a native getter is detectable four ways. First, Function.prototype.toString on the replacement doesn't say [native code] unless you also mask toString — and then your masked toString is itself detectable. Second, property descriptors change: enumerability, configurability, and whether the property lives on the instance or the prototype. Third, Reflect.ownKeys and Object.getOwnPropertyNames ordering can shift. Fourth, and most decisively, cross-realm comparison: create an <iframe>, reach into contentWindow.navigator, and read the same property from that fresh realm. Most patches only touch the top-level realm, so the iframe tells the truth. The same applies to Web Workers — spawn a worker, read navigator.hardwareConcurrency inside it, and compare. A main-thread patch never reaches there.
This is the strongest technical argument for doing fingerprint spoofing below JavaScript, in the browser engine itself, so every realm and every worker returns the same coherent answer because there is nothing to patch. We wrote up what that looks like in practice in how to change your browser fingerprint.
isTrusted: synthetic events versus real input
Every DOM event carries an isTrusted boolean. Events created by new MouseEvent() and dispatched from script are false. Events created by the browser in response to actual input — or to CDP's Input.dispatchMouseEvent, which enters at the browser-process level rather than the page level — are true.
This single property collapses an entire category of scraping tooling. If your clicking is element.click() or dispatchEvent, a login form can reject you before it validates the password. Driving input through Input.* CDP commands, or through OS-level synthesis, produces trusted events. It is slower and you have to care about coordinates and scroll position, but it is the difference between working and not working on any site that checks.
Headless isn't the problem; headless defaults are
Old headless Chrome was its own browser implementation with a long list of missing pieces: no window.chrome, empty navigator.plugins, Notification.permission stuck at denied, a distinctive HeadlessChrome UA token, and a WebGL renderer string of Google SwiftShader. Chrome's new headless mode fixed the architectural part — it is the same binary, same renderer, same feature set.
What it did not fix is everything around it. A headless container typically has no GPU (so software rendering shows up in your WebGL strings), no fonts beyond a handful of DejaVu faces, a screen resolution that matches nothing real, devicePixelRatio of exactly 1, no media devices, availHeight === height because there's no taskbar, and a media-codec list missing the proprietary ones. Any one of those is a weak signal; together they're a confident classification. If you must run without a visible display, budget for a font package, a real GL path, and a screen geometry that corresponds to a device someone actually owns.
The mismatch class of tells
Separate from any individual value is the question of whether your values agree with each other. A macOS User-Agent with Windows font metrics. Accept-Language: en-US from a São Paulo IP with a Europe/Kyiv timezone. navigator.platform of Win32 next to a WebGL renderer string from an Apple GPU. Nine plausible values arranged implausibly score worse than one boring, consistent device. Internal consistency is the whole ballgame, which is also why hand-editing individual fingerprint fields tends to make things worse rather than better.
Transport-level detection you cannot patch from inside the page
TLS and HTTP/2 signatures
A real Chrome build produces a very particular ClientHello. Reproducing it from a generic HTTP library is a specialised job — the curl-impersonate project exists precisely because getting cipher order, extension order and GREASE right by hand is miserable. If your pipeline is HTTP-only (no browser), a TLS-impersonating client is not optional at scale; it's the first thing to fix.
If your pipeline runs a real Chromium binary, you get the correct handshake for free. This is the underrated advantage of browser-based scraping: you are not imitating Chrome, you are Chrome, all the way down to the socket. The cost is RAM and CPU per worker. The benefit is that an entire detection layer becomes a non-issue.
IP reputation is the largest single variable
I want to be blunt here because it saves people months. If you are being challenged constantly and you're on datacenter IPs, your fingerprint is not the problem. Cloud ASNs are labelled. Shared datacenter ranges carry the accumulated sins of everyone who used them before you. Residential and mobile egress changes outcomes more than any code change you can make.
It also has to match. An IP in Ohio paired with a Berlin timezone and German locale is a mismatch signal, not a disguise. And WebRTC will happily announce your real address over STUN unless it is masked to the proxy exit — a leak that survives every other precaution. Our residential proxy playbook covers sticky-session sizing, rotation policy and how to test for leaks before you trust a pool.
This is also the honest answer to the perennial VPN question: a VPN moves your IP and nothing else, so every device-level signal still says one machine, many accounts. The difference between an antidetect browser and a VPN is that one changes where you appear to be and the other changes who you appear to be. Scraping at scale needs both.
Bypass methods ranked by durability
Here is how the common approaches actually hold up. Durability means: how long does this keep working as detection vendors ship updates?
| Method | What it addresses | Durability | How it typically fails |
|---|---|---|---|
Launch flags (--disable-blink-features=AutomationControlled, UA override) |
webdriver flag, infobar, UA string |
Low | Flag side effects are themselves observable; UA contradicts UA-CH and real capabilities |
JS stealth plugins patching navigator |
Runtime-layer property values | Low–medium | Cross-realm iframe reads, worker realms, toString and descriptor checks |
| Engine-level (native) fingerprint spoofing | Canvas, WebGL, audio, fonts, navigator, screen — in every realm | High | Only if values are internally inconsistent or reused across identities |
Real browser binary, no Runtime.enable, no held debugger |
CDP and debugger-attach detection | High | Convenience APIs sneak Runtime back in; audit your driver |
Trusted input via Input.* CDP or OS synthesis |
isTrusted, event ordering |
High | Perfectly linear mouse paths and exact-centre clicks |
| Residential or mobile egress matched to identity | IP reputation, geo consistency | High (but the main cost line) | Rotating mid-session; pool contamination; WebRTC leak |
| TLS/HTTP2 impersonation (HTTP-only stacks) | Transport signature | Medium–high | Falls behind when Chrome changes its handshake |
| Persistent profile with real cookies and history | Trust and reputation signals | High | Sharing one data dir across identities; corrupting the profile |
| Behavioural pacing and jitter | Velocity and sequence heuristics | High | Jitter that is uniformly random is its own pattern |
| Captcha-solving services | One challenge, once | Low | Treats the symptom; the score that triggered it is unchanged |
The pattern is consistent: methods that change what your stack is are durable. Methods that hide what your stack is are temporary.
Building a scraping stack that stays quiet
Start from a real browser, not a wrapper
Use an actual Chromium build with a real GPU path, real fonts, real codecs. Launch it as an ordinary OS process. Do not attach a debugger and hold it open for the session lifetime — connect briefly when you need to act, and let go. A permanently attached CDP client is a state a detector can see; a connection that lasts 200ms during a click is much harder to notice.
Give every worker its own identity and keep it
One worker, one profile, one data dir, one fingerprint, one proxy. Persist it. This is the part people skip when they're in a hurry, and it's the part that pays compounding returns, because a profile that has visited a site twenty times over three weeks with the same cookies is trusted in a way a fresh browser can never be. Warm profiles get fewer challenges. Warm profiles also survive a change in your scraper's behaviour that a cold one wouldn't.
The corollary: never share a data dir between two identities, and never reuse a fingerprint across profiles. Two accounts with byte-identical canvas hashes is the easiest link a platform will ever draw. If you're deciding how to organise this at team scale, the trade-offs are laid out in our guide to running multiple accounts.
Pace it like a person who has other things to do
Human traffic is bursty and interrupted. Machine traffic is metronomic. The fixes are unglamorous:
- Sample delays from a long-tailed distribution, not
random.uniform(2, 5). Real gaps include a 40-second one where someone answered the phone. - Move the mouse before clicking, with acceleration and a slight overshoot, and land off-centre on the target.
- Scroll before interacting with something below the fold. Elements that get clicked without ever entering the viewport are a strong tell.
- Type with variable inter-key gaps, and occasionally correct a character.
- Vary navigation order across workers. If all 200 of them hit
/category/1then/category/2in lockstep, the fingerprints don't matter. - Respect an off-hours curve. A target audience in one timezone that generates identical load at 04:00 is asserting something about itself.
Read the challenge as a signal, not an obstacle
When you start getting challenged, resist the urge to reach for a solver. A challenge is telemetry: something in your stack crossed a threshold. Bisect it. Run the same target from a clean, hand-driven profile on the same proxy — if that's fine, the problem is your driving. Swap the proxy and keep the driving — if that's fine, the problem is egress. Do this before writing code, and you'll fix causes instead of accumulating patches.
Keep canary profiles
Maintain a couple of profiles that only ever browse the target manually and never run automation. They're your control group. When they start seeing challenges too, the site changed. When only your workers see them, you changed. Without a control you will spend days debugging a detection update as though it were your own bug.
How to test whether your bypass actually works
Bot-detection test pages are useful and also misleading. They tell you about layer 2 and some of layer 3, in isolation, with no reputation history and no behavioural component. Passing them all is necessary, not sufficient — and a couple of them flag things that no real site checks, which sends people chasing ghosts.
A better test regime has four parts.
Consistency checks first. Before you go near a target, verify that your fingerprint agrees with itself. Read the same properties from the top-level realm, an iframe realm, and a Web Worker, and diff them. Any disagreement is a bug you'll otherwise discover as an unexplained ban weeks later. Then check that timezone, locale, UA-CH platform and the WebGL vendor triple all describe one device.
Entropy check second. EFF's Cover Your Tracks will tell you how unusual your configuration is. The target is not maximum uniqueness — it's plausible uniqueness. You want to look like one of ten million Windows laptops, not like the only browser on earth reporting 512 fonts and 128 CPU cores.
Transport check third. Hit an endpoint that echoes your TLS and HTTP/2 signature and confirm it matches the Chrome version you claim. If you're running a real binary this should be automatic; if it isn't, something in your proxy chain is terminating and re-originating TLS.
Then measure in production. The only metric that matters is challenge rate and block rate per profile cohort, tracked over time. Instrument it. Tag every request with which profile, which proxy pool and which pacing policy produced it, then compare cohorts. A stack you can't measure is a stack you're tuning by vibes.
Where each layer breaks in practice
A short catalogue of failure modes I keep seeing, in roughly descending order of frequency:
Proxy rotation mid-session. A session that changes IP between page one and page two of a paginated result is not a human. Sticky sessions long enough to complete a logical unit of work are essential; rotate between units, not inside them.
One fingerprint, many profiles. Usually caused by generating fingerprints from a seed that isn't actually unique, or by cloning a profile directory. Audit for duplicates directly — don't assume your generator did the right thing.
Fingerprint drift. The same profile reports a different canvas hash or a different screen size each launch. Drift is worse than a static odd value, because it's a signal no real device produces. Whatever you use, the fingerprint must be pinned to the profile and stable across restarts and across machines.
The forgotten worker or iframe. Covered above; it's the single most common way stealth-plugin stacks get caught.
Leaked automation extensions. An extension loaded for scraping convenience is enumerable through several channels, including web-accessible resource probing. If you must load one, know that it is visible.
Success measured as HTTP 200. Soft blocks return 200 with poisoned or truncated content. If your validation is a status-code check, you will happily scrape thousands of pages of garbage. Validate on content invariants — a field that must exist, a count that must be plausible.
The parts that aren't technical
Two things worth saying plainly, because they affect stack design more than people expect.
First, legality and terms. Public-data scraping and terms-of-service violations are different questions with different consequences, and both vary by jurisdiction. Read the target's terms, honour robots.txt where it applies, don't scrape personal data you have no basis to process, and don't hammer infrastructure hard enough to degrade it for real users. Rate-limiting yourself out of politeness happens to be the same engineering decision as rate-limiting yourself to stay unblocked.
Second, cost. Residential egress is the dominant line item at scale, followed by RAM for concurrent browsers. This is why hybrid stacks win: use a TLS-impersonating HTTP client for the 80% of endpoints that don't run client-side detection, and spend your browser instances on the 20% that do. Cache aggressively. Don't re-fetch what hasn't changed. Teams comparing tooling on total cost rather than sticker price will find our buyer's guide for small teams more useful than a feature grid.
Putting it together: a reference architecture
If I were starting a scraping operation tomorrow, this is the shape I'd build.
A profile pool where each entry owns a fingerprint, a persistent data dir and an assigned proxy identity, all stable for the profile's lifetime. A scheduler that assigns work to profiles with cooldowns, so no profile is used more often than a human would use a browser, and that spreads workload across the day rather than draining a queue at full speed. A driver that speaks raw CDP with no Runtime.enable, dispatches trusted input, and connects only for the duration of an action. A validation layer that checks content invariants and classifies outcomes into success, soft block, hard block and challenge. A metrics layer that tracks those four outcomes per cohort. And canary profiles outside the pool, driven by hand.
Notice how little of that is about clever evasion. The clever part is architectural discipline: real browser, stable identities, matched egress, honest pacing, measured outcomes. The browser automation detection bypass methods that survive contact with production are almost always the boring ones done properly.
FAQ
Does undetected-chromedriver still work in 2026?
Partially, and unpredictably. It patches the well-known ChromeDriver artefacts, which handles the shallowest layer of detection. It does not give you a coherent per-profile fingerprint, does not manage egress, and — because the WebDriver model depends on script evaluation — does not solve the debugger-attach and Runtime class of tells. It's fine for low-defence targets and a poor foundation for anything at scale.
Is headless Chrome always detectable?
No, but headless containers usually are. The new headless mode is the same binary as headful, so the browser itself isn't the giveaway. What gives you away is the environment: software rendering, three fonts, no media devices, and a screen size no device ships with. Fix the environment and headless becomes viable; leave it at container defaults and no amount of JavaScript patching helps.
If I already use residential proxies, do I need an antidetect browser?
If you're running more than one identity from one machine, yes. Proxies fix the network layer. They do nothing about the fact that all your sessions render canvas identically, report the same GPU, the same font list and the same audio signature — which is exactly how platforms link accounts. The two solve different halves of the same problem.
Can Cloudflare or DataDome detect Puppeteer even with stealth plugins?
Regularly, yes. The two reliable routes are cross-realm inspection (read a patched property from an iframe or worker and compare) and debugger-attach detection via Runtime-domain side effects. Neither is addressed by patching navigator from inside the page. Getting past them means changing the architecture — spoof below JavaScript, and drive without enabling Runtime.
What single change gives the biggest improvement?
Switching from datacenter to well-matched residential or mobile egress, with a persistent profile per identity. It's also the most expensive change, which is why people try everything else first. If you're already on good egress and still being blocked, look next at whether your driver enables the Runtime domain and whether your clicks produce trusted events.
Is any of this legal?
Depends entirely on what you scrape and where. Collecting public data is broadly defensible in many jurisdictions; breaching a contract you agreed to, circumventing technical access controls on protected material, or processing personal data without a lawful basis are separate risks with real teeth. Get advice for your specific case, keep your request rates considerate, and don't treat a technical capability as a legal permission.
Closing thought
The reason flag lists keep circulating is that they're easy to copy and they used to work. The reason they don't work now is that detection moved down the stack — into the handshake, into the debugger protocol, into the coherence between one value and another — and you can't reach those places from a launch argument.
What you can do is build on a foundation that doesn't need hiding: a genuine browser engine that applies each profile's fingerprint natively so every realm and worker agrees, an isolated data dir per identity so logins persist and reputation accrues, an exit IP that matches the story the browser tells, and an automation layer that never enables Runtime.enable and dispatches input the browser considers trusted.
That's the stack Dual Login is built to be. If you're currently maintaining a pile of patches and wondering why the challenge rate keeps creeping up, spin up a few profiles, point them at your hardest target, and compare the numbers against what you're running today. Bring a canary profile — the comparison is only meaningful with a control.