Undetectable Browser Automation Without Selenium (2026)
Every few weeks the same question lands in a scraping forum or a Discord channel: my Selenium script ran fine for two months and now every session gets a checkbox, an SMS code, or a silent shadowban. What changed?
Usually nothing changed on the detection side that week. What changed is that the account, the IP, or the fingerprint finally accumulated enough signal to cross a threshold. Selenium was never invisible. It was simply below the bar that particular site cared about — until it wasn't.
This article is about what to do instead. Not another list of stealth plugins, but an actual architecture: where the fingerprint belongs, how to drive a page without the control channel announcing itself, which parts of the problem automation cannot fix at all, and how to tell whether your setup is genuinely working or just hasn't been caught yet.
One honest caveat before anything else. There is no such thing as undetectable in the absolute sense. A site with enough traffic, enough engineering budget and enough patience can always find a way to distinguish you eventually. Undetectable browser automation without Selenium, in the practical sense that matters, means two things: you emit no signal that is categorically different from a human on an ordinary machine, and nothing about your behaviour invites a closer look. That is achievable. Perfection is not.
What detection actually looks at
Before fixing anything, it helps to know what is being measured. Modern bot management stacks — Cloudflare, Akamai, DataDome, HUMAN, and the in-house systems at Google and Meta — score you across five roughly independent layers.
Layer 1: automation artifacts. Direct evidence that a driver is attached. navigator.webdriver, leftover globals from the driver binary, DevTools domains that are enabled when no developer is present. This layer is binary and cheap to test, which is exactly why you lose here first.
Layer 2: environment fingerprint. Canvas and WebGL output, audio context hashing, installed fonts, screen geometry, hardwareConcurrency, deviceMemory, user agent, Client Hints, timezone, languages, media devices. Individually these are weak; combined and cross-checked they are extremely strong. If you want the full mechanics of how these are collected and forged, we covered them in detail in what an antidetect browser is and how it works.
Layer 3: network identity. The exit IP's reputation and ASN class (datacenter, residential, mobile), whether the geolocation agrees with the declared timezone and locale, and at the transport level your TLS and HTTP/2 fingerprint.
Layer 4: behaviour. Dwell time, mouse trajectories, scroll physics, typing cadence, the order in which you touch pages, the intervals between requests, and — the one people forget — whether ten of your sessions do the identical thing within the same second.
Layer 5: account history. Age of the account, the devices and IPs it has used before, its normal activity envelope. On Facebook, Google and most marketplaces this layer outweighs the other four combined. A three-year-old account with a consistent device does things a three-day-old account cannot.
Selenium breaks layer 1 loudly. That is the whole story of why it gets caught. But fixing layer 1 while ignoring the rest only moves the failure later — which is why people who switch tools report a honeymoon period followed by the same blocks.
Why Selenium is detectable by design
It is worth being precise here, because a lot of the folk wisdom is wrong.
navigator.webdriver is a feature, not a bug
The W3C WebDriver specification defines a webdriver-active flag that a conforming implementation must set, and HTML exposes it to pages as navigator.webdriver. This was deliberate: the standards authors wanted sites to be able to know they were being driven, so that they could disable destructive test paths. Read the WebDriver spec and you will find it stated plainly.
So the most famous Selenium tell is not an oversight anyone will fix. It is the contract. Any tool built on WebDriver starts the race by raising its hand.
You can, of course, patch the property back to false from a page script that runs before the site's code. But now you have a patched property rather than a native one, and patches have their own signature: a property descriptor that sits on the wrong object in the prototype chain, a getter whose Function.prototype.toString does not read as native code, a value that differs between the main frame and a nested iframe. Detectors check for the patch, not just the value.
The driver leaves fingerprints in the document
ChromeDriver has historically injected its own bookkeeping globals into pages — the notorious cdc_-prefixed properties such as cdc_adoQpoasnfa76pfcZLmcfl_Array hanging off document or window. A three-line loop over window property names finds them. The community response was to hex-edit the driver binary and rename the strings, which works right up until the detector switches from matching the exact name to matching the shape of the name.
The automation switches
--enable-automation produces the yellow Chrome is being controlled by automated test software infobar, and it also flips a cluster of preferences: password manager prompts suppressed, certain popup and permission behaviours changed, some default extensions disabled. Each of those is indirectly observable. Turning the infobar off with --disable-infobars hides the symptom and keeps the pref changes.
The patch treadmill
undetected-chromedriver, selenium-stealth and puppeteer-extra-plugin-stealth all work the same way: run a large blob of JavaScript before the page's own code and rewrite everything known to be detectable. Two structural problems follow.
First, the patch itself is code running in the page, and it leaves traces — overridden descriptors, wrapped natives, timing anomalies during the pre-load, and inevitably some surface it forgot. Web Workers are the classic gap: a detector spawns a worker, reads navigator.hardwareConcurrency or renders to an OffscreenCanvas inside it, and gets the unpatched truth because the shim only ever touched the main world.
Second, and more fundamentally: these are popular public repositories. Whoever writes the detector reads exactly the same source you do. Anything defended by a widely used open patch is the first thing a serious anti-bot vendor tests, because it is the highest-yield check they can write.
What Selenium actually gets right
Fairness matters, because a misdiagnosis leads to the wrong replacement. Selenium's input is trusted. When WebDriver clicks an element it does not call element.click() from JavaScript; it dispatches through the browser's own input pipeline, so the resulting event has isTrusted === true just like a human click. Sites that check isTrusted do not catch Selenium.
Which means the fix is not simply switching to Puppeteer or Playwright. Their default builds and their default use of DevTools carry their own tells, and swapping libraries without changing the model buys you a few weeks at best.
The one CDP call that matters
If you take a single technical idea away from this article, make it this one.
Puppeteer and Playwright enable the Runtime domain of the Chrome DevTools Protocol as part of connecting. They have to — page.evaluate() is built on it, and so is most of their internal plumbing for waiting on selectors and reading values out of the page.
The problem is what enabling Runtime does to the browser's behaviour. Once the domain is on, the browser begins serialising console arguments and exception details back to the client, and it announces an execution context for every frame. That serialisation is observable from inside the page. The canonical proof of concept creates an object with a getter, or an Error whose stack access is instrumented, and simply waits: if the getter fires when no developer tools window is open and no page code touched the value, something on the other end is reading it. That is a one-bit answer to the question is this session being driven, and it does not care which library you used.
This is why the patched forks of Puppeteer that circulate in scraping circles focus almost entirely on removing Runtime.enable and replacing evaluation with isolated worlds created through Page.createIsolatedWorld and bindings.
What you can do without Runtime
Here is the encouraging part. Almost every action you actually need is available in domains that make no such noise:
- DOM —
getDocument,querySelector,getBoxModel,getOuterHTML,describeNode, attribute reads - Input —
dispatchMouseEvent,dispatchKeyEvent,dispatchTouchEvent,insertText - Page —
navigate, lifecycle events,captureScreenshot, history - Network — request and response capture, header control
- Target — enumerate, create, activate and close tabs
- Storage — read and write cookies directly, which is how portable logins are captured
What you lose is arbitrary JavaScript evaluation. In practice that turns out to be a design constraint rather than a handicap: it forces you to interact with the page the way a person does, through coordinates and keystrokes, instead of reaching into the DOM and setting a value that no human interaction could have produced.
And the input you dispatch this way is trusted. CDP Input events originate on the browser side of the boundary, so isTrusted is true, focus and blur fire in the right order, and React or Vue handlers that ignore synthetic events behave normally.
Driving without eval, concretely
A click becomes: resolve the selector with DOM.querySelector, get the box with DOM.getBoxModel, compute a point that is not the exact geometric centre, dispatch a short mouseMoved sequence toward it, then mousePressed and mouseReleased with a plausible gap between them.
Typing becomes: Input.dispatchKeyEvent per character with rawKeyDown, char and keyUp, with jitter between keys and the occasional longer pause. Use insertText for bulk paste-like fields if you must, but never for a password or a search box where a site is watching keystroke timing.
Waiting becomes: Page lifecycle events plus polling the DOM, rather than injecting a promise into the page.
Reading a value becomes: DOM.getOuterHTML on the subtree you care about and parsing it on your side, or the Accessibility domain when you want the rendered text rather than the markup.
When you genuinely cannot avoid running JavaScript in the page — reading a value that only exists in a closure, say — do it in an isolated world, and do it on a page whose loss you can afford. Not on the login screen of the account you have spent six months warming.
The node-id trap nobody warns you about
DOM node ids in CDP are handles into a per-tab map, and that map is invalidated whenever the document is re-fetched. If two of your actions run concurrently against the same tab, one call to DOM.getDocument resets the backend node ids the other is holding, and you get an intermittent could not find node with given id that looks like flakiness rather than a race.
The fix is to serialise resolve-and-act as a single atomic unit per tab, behind a promise queue. Never resolve a node in one call and act on it in another that could interleave. This is the single most common bug in a first hand-rolled CDP driver, and because it only appears under concurrency, it survives every test you write on one tab.
Where the fingerprint should live
Driving quietly solves layer 1. Layer 2 is a separate problem with its own wrong answer.
The wrong answer is injected JavaScript spoofing. Overriding navigator.platform, wrapping HTMLCanvasElement.prototype.toDataURL, monkey-patching WebGLRenderingContext.prototype.getParameter — this is where most homegrown stealth ends up, and it is structurally weak. The overrides are visible as overrides. They miss workers, they miss nested and cross-origin iframes, they miss service workers, and they miss the C++ paths that produce the actual pixels a canvas hash is computed from.
The strong answer is to spoof below JavaScript entirely, in the browser engine itself, so the values are simply what the browser reports. There is no wrapper to unwrap because there is no wrapper. A properly built antidetect engine reads a per-profile identity at startup — ours reads a signed, encrypted configuration bound to that profile's data directory — and applies it before the first line of page script executes. Canvas noise happens in the rasteriser, WebGL strings come from the driver layer, and a Web Worker sees the same spoofed world the main frame does, because there is only one world.
Consistency is harder than any single value
Most people get the individual values plausible and the combination fatal. Real examples that will end a session on a serious site:
- User agent claims macOS while the WebGL renderer string names a Direct3D ANGLE backend
- Timezone set to Europe/London while the exit IP geolocates to São Paulo
Accept-Language: en-USalongside a German residential proxy and an Asia/Dhaka timezone- Twelve CPU cores paired with 2 GB of
deviceMemoryon a 1366x768 screen Sec-CH-UA-Platformdisagreeing withnavigator.platform, which catches everyone who spoofs the UA string and forgets Client Hints exist- A device pixel ratio that no real display of that resolution ships with
EFF's Cover Your Tracks is a good way to build intuition about how much entropy a fingerprint carries, though treat its uniqueness score as directional only — it measures you against EFF's own visitor pool, which skews heavily toward privacy-conscious users and is not representative of the traffic a commercial site sees.
Comparing the realistic options
| Approach | How it drives the page | Built-in automation tells | Fingerprint control | Setup effort | Sensible use |
|---|---|---|---|---|---|
| Selenium + ChromeDriver | WebDriver protocol | navigator.webdriver, driver globals, automation switches |
None | Low | Testing your own apps |
| Selenium + undetected-chromedriver | WebDriver, patched binary | Most obvious ones removed; patch itself is public and fingerprintable | Minimal | Low | Low-value scraping, short horizons |
| Puppeteer / Playwright, defaults | CDP with Runtime enabled | Runtime serialisation observable; default builds and args | Weak | Low | Internal automation, no adversary |
| Puppeteer / Playwright + stealth plugin | CDP with Runtime enabled | Main-world patches only; workers and iframes leak | Superficial | Low | Sites with no real bot management |
| Patched CDP fork, isolated worlds | CDP, Runtime never enabled | Few — this closes the biggest one | Still your problem | Medium | Scraping at scale with your own fingerprint layer |
| Antidetect engine + raw CDP | CDP, narrow domain set, trusted input | Effectively none on the control channel | Native, per profile, consistent | Medium | Logged-in accounts, long-lived identities |
| Pure HTTP replay, no browser | Direct requests with a cookie jar | No browser to fingerprint at all | N/A — TLS becomes the fingerprint | High | High-volume reads with no client challenge |
Notice the pattern down the fingerprint column. Every approach that fixes the control channel still leaves layer 2 entirely to you. That is the gap an antidetect browser fills, and it is why the two halves — quiet driving and native fingerprints — belong in the same system rather than being bolted together.
A practical architecture you can build this week
One profile per identity, and make it persist
Each identity gets its own user data directory. Cookies, localStorage, IndexedDB, service worker caches and the site's own device tokens all live inside it and survive between runs. Two consequences follow, and people usually only internalise the first.
Reusing one profile across ten accounts links them permanently — shared storage, shared fingerprint, shared everything. Everyone knows this. The less obvious mistake is the opposite: starting from a fresh profile on every run for a logged-in workflow. Real people have history. An account that arrives from a device with no cookies, no cache, no prior visit and no stored token, every single day, is a device that does not exist. For anything session-based, the profile has to accumulate.
Once you are running more than a handful, the organisational side starts to matter as much as the technical side. We wrote up the conventions that hold up at scale in browser profile management best practices.
Generate the fingerprint once and freeze it
A surprising number of setups regenerate the fingerprint at every launch, which means the same account reports a different GPU, a different screen and a different core count every morning. No hardware behaves that way. Generate at profile creation, store it with the profile, and change it only when a real user would — a new machine, a monitor swap, a major OS upgrade. Even then, change one plausible cluster of values, not all of them.
Attach late, detach early, or never attach at all
Every second a debugging client is connected is a second of exposure. The pattern that works: launch the browser completely clean, with no framework attached and no automation switches, let the profile do its human-paced thing, and connect over the debug port only for the specific action you need to script. Then disconnect.
This matters most on the platforms that watch hardest. Google properties in particular react badly to a full framework attaching and enabling its usual domain set on a live authenticated tab — sessions get invalidated in ways that look like a cookie problem and are not. If a workflow includes a Google login, do the login by hand or with the narrowest possible domain set, and never hold an open Runtime connection on that tab.
Proxies that match the story the browser tells
Datacenter IPs are pre-scored badly on most consumer platforms regardless of what your browser reports. Residential and mobile exits cost more and behave far better. Whichever you choose, the session must be sticky per profile: an identity whose ASN changes mid-flow is the loudest single signal you can produce, louder than anything in layer 1.
Then make the browser agree with the proxy. Timezone, locale, Accept-Language, geolocation permission responses and WebRTC's reported address all have to point at the same place as the exit IP. The proxy-side reasoning is covered more fully in our guide to web scraping without getting blocked.
Pace it like a person
Cap concurrency well below what your hardware allows. Add jitter everywhere — between actions, between sessions, between accounts. Respect working hours for the timezone you are pretending to be in. Warm new accounts with days of ordinary reading before they do anything transactional. Ten profiles that log in at exactly 03:00:00 are a single pattern wearing ten costumes.
Fail closed, always
When the proxy dies, the profile must not fall back to a direct connection. When the fingerprint configuration cannot be written, the browser must not launch in vanilla mode. When a cross-machine session cannot be verified, the profile should refuse to open rather than open on a stale login. Silent fallbacks are how an entire batch of accounts gets burned in a single unattended run, and the cost of not opening is always smaller than the cost of opening wrong.
When you should not run a browser at all
There is a whole class of work where the answer to undetectable browser automation without Selenium is: do not automate a browser.
Record a session once, capture the request sequence, work out which response values feed later requests — CSRF tokens, session identifiers, signed nonces — and replay the whole thing as plain HTTP with a cookie jar and token re-extraction. It is fifty to a hundred times cheaper in CPU and memory, it has no rendering surface, no canvas, no WebGL, and no automation artifacts because there is no automation framework.
The trade-off is that your fingerprint moves down the stack. TLS handshake ordering, cipher suite lists, HTTP/2 frame and header ordering, ALPN — collectively JA3 and JA4 — become the thing that identifies you, and a default Python or Node HTTP client has a fingerprint that matches no real browser on earth. Any client-side challenge, from a Turnstile widget to a behavioural sensor payload, is also out of reach.
The rule of thumb I use: read-only, high volume, no client-side challenge, no session you care about losing — replay it as HTTP. Anything that touches an authenticated identity with value attached — run a real browser.
How to know if it is actually working
Test against a control, not against a score
Run the same site, at the same time of day, through the same class of proxy, from a genuinely ordinary browser on an ordinary machine. That is your control. Everything your automated profile does that the control does not is a delta worth explaining. Absolute results from a fingerprint test page mean much less than the difference between the two.
Fingerprint test sites are diagnostics, not verdicts
Browserscan, CreepJS and their relatives are genuinely useful for spotting internal contradictions — they will tell you that your Client Hints disagree with your user agent, or that a worker reports a different core count than the main thread. Fix everything they flag. But a green badge is not a pass. Those sites test what they know how to test; Meta's risk engine tests your account's five-year history. Do not confuse the two.
Test the surfaces patches miss
Specifically: read your spoofed values from inside a Web Worker, from inside a same-origin iframe, from inside a cross-origin iframe, and from a service worker. If a value is right in the main frame and wrong in a worker, you have a JavaScript patch pretending to be a fingerprint.
Watch for silent degradation, not just hard blocks
A hard block is the kind outcome. The expensive one is the shadowban: posts that publish but reach nobody, listings that exist but never appear in search, an account that works perfectly and converts nothing. Track outcome metrics per profile — reach, impressions, response rates — and treat a slow decline as a detection event, because that is what it usually is.
Do not debug captchas with fingerprint changes
If you are getting challenged constantly, the odds strongly favour IP reputation over anything in your browser. Swap to a cleaner exit and re-test before you spend a week tuning canvas noise. This one misdiagnosis probably wastes more engineering hours than every other item in this article combined.
Mistakes that cost people accounts
Spoofing the user agent and nothing else. Client Hints, navigator.platform, the WebGL renderer and the font list all have to move with it.
Running twenty profiles from one IP. The fingerprints can be flawless and the IP still links them.
Copying a profile folder to clone an identity. You have now cloned the device tokens and storage the site uses to link accounts. Create a new profile; import the cookies you actually need.
Holding a CDP connection open all session. Attach for the action, then leave.
Regenerating fingerprints on a schedule. Hardware does not change nightly.
Automating the login itself on hardened platforms. Log in by hand once, capture the session, and automate what comes after. This alone changes survival rates on the strict platforms — the reasoning is spelled out in our notes on managing multiple accounts safely.
Treating a fresh profile as safer than an aged one. For logged-in work the opposite is true.
No kill switch. If you cannot stop every profile in one action when something goes wrong, you will find out how much damage an unattended loop can do.
FAQ
Is Selenium always detectable?
By default, yes — the WebDriver specification requires the webdriver-active flag that surfaces as navigator.webdriver, and ChromeDriver adds its own artifacts on top. Patched forks remove the obvious ones, but they are public code that detector authors read too. If the site you are automating has no bot management, Selenium is fine. If it does, you are betting on a patch that your adversary has already reverse-engineered.
Does headless mode still matter in 2026?
Less than it used to. Since Chrome's newer headless mode shares the same browser binary as headed Chrome, the old giveaways — missing plugin arrays, a broken permissions API, an absent GPU — are largely gone. What remains is the user agent, which still contains HeadlessChrome unless you override it, plus subtle differences in window and screen metrics. For anything account-related I still run headed windows, because the behavioural surface of a real window is easier to make plausible than to fake.
Can I just use undetected-chromedriver and move on?
For low-value scraping against unprotected sites, sure. For anything holding a session you care about, no. It fixes exactly one layer of the five, does nothing for your environment fingerprint, and its patches are the first thing a commercial anti-bot vendor writes a check for. It buys time, not safety.
Is CDP itself detectable?
CDP as a transport is not visible to the page. What is visible are the side effects of certain domains — Runtime.enable above all, because the browser then serialises console output and exception details in a way a page can observe. Restrict yourself to DOM, Input, Page, Network, Target and Storage and there is nothing for the page to see. This is precisely why a hand-rolled raw-CDP driver beats a general-purpose framework here: the framework enables Runtime for you whether you need it or not.
Do I need an antidetect browser if I only scrape public pages?
Often not. If the data is public, there is no login to lose, and the site's protection is light, a well-behaved HTTP client with rotating residential proxies and sane rate limits is cheaper and faster. Reach for an antidetect browser when there is an authenticated identity involved, when the site runs client-side challenges, or when you need many identities that must never be linked to each other.
Is any of this legal?
Terms of service and law are different things, and the answer varies by jurisdiction, by site and by what you do with the data. Scraping public information, running several accounts you legitimately own, and testing your own applications sit in very different places from credential stuffing or fraud. Get advice for your specific situation rather than from a blog post — including this one.
Wrapping up
The reason Selenium keeps getting caught is not that it is old or badly written. It is that WebDriver was designed to be identifiable, and every stealth patch since has been an attempt to argue with that design from inside the page — where the argument is visible.
The alternative is not a cleverer patch. It is a different shape: an engine that reports a coherent identity natively because that identity was applied before any page script ran, a persistent profile that accumulates history like a real device, a proxy whose geography agrees with the browser's story, and a control channel that only ever speaks the DevTools domains a page cannot observe. Get those four right and layer 1 stops being how you lose. Then you can spend your attention on the layers that actually decide outcomes — behaviour and account history.
Dual Login was built around exactly that model: native per-profile fingerprints written into the engine rather than injected as JavaScript, an isolated data directory per identity so logins persist and stay separate, per-profile proxies with matching timezone and locale, and an automation API that drives pages over raw CDP with trusted input and no Runtime.enable on the path. If you would like to see how your current setup compares, spin up a couple of profiles and run them side by side against your own control — our notes on what to test before you pay for any antidetect browser are a reasonable checklist to work through while you do.