Dual Login
Automation

Puppeteer Stealth Mode Not Working? Solutions That Work

Dual Login Team·2026-08-08·19 min read

Puppeteer Stealth Mode Not Working? Solutions That Work

A practitioner's debugging order for when puppeteer-extra-plugin-stealth stops fooling anyone — what it patches, what it never touched, and what to do instead.

Puppeteer Stealth Mode Not Working? Solutions That Work

You installed puppeteer-extra-plugin-stealth, opened bot.sannysoft.com, watched every row turn green, and shipped. For a while it held. Then the login page started asking for a verification code on runs that used to sail through. Then the target began serving a challenge on the third request instead of the three-hundredth. Then, on a Tuesday, a scraper that had been stable for seven months started failing in a way you could not reproduce on your laptop.

If you are hunting for puppeteer stealth mode not working solutions, you are almost certainly standing at one of three places: the plugin never worked on your target at all, it worked and then quietly stopped, or it works locally but collapses the moment you move to a server. Those three symptoms have different causes, and the internet's standard advice — add more plugins, add more flags, add a random user agent — makes two of them worse.

This is a debugging order, not a list of magic flags. We will go through what the stealth plugin actually patches, the categories of signal it was never able to reach, how to tell which one is burning you, and where the honest ceiling of the patch-a-real-browser approach sits.

Developer debugging why puppeteer stealth mode is not working, with solutions shown on screen

First, be precise about what the stealth plugin does

The plugin is a collection of small JavaScript patches — around seventeen of them — injected into every page and frame before any site code runs. They are individually sensible and collectively narrow. Grouped by what they fix:

  • The obvious automation flags. navigator.webdriver set to undefined, and the launch arguments adjusted so Chromium does not advertise itself (--enable-automation removed, --disable-blink-features=AutomationControlled added).
  • The missing furniture of a real Chrome. window.chrome.app, chrome.csi, chrome.loadTimes, chrome.runtime — objects present in consumer Chrome that headless builds historically lacked.
  • Property mismatches. navigator.plugins, navigator.languages, navigator.vendor, navigator.hardwareConcurrency, navigator.permissions so the Notification state and the Permissions API stop contradicting each other.
  • Rendering strings. The WebGL vendor and renderer, so SwiftShader does not announce a headless VM.
  • Frames and windows. iframe.contentWindow, window.outerWidth and outerHeight, plus a sourceurl patch so injected script stack traces do not carry a puppeteer_evaluation_script filename.

Every one of those is a patch to what the page can read about the browser. Not one of them touches how the browser behaves, how it connects, or who owns the IP address. That distinction is the whole article.

The maintenance problem nobody mentions in the README

The second thing to internalise is calendar-shaped. The puppeteer-extra project has been effectively frozen for a long stretch, while Chromium ships a stable release roughly every four weeks and detection vendors ship whenever they feel like it. A patch written against Chrome 100 semantics is not neutral in Chrome 140 — it is a difference.

That produces the failure mode most people never consider: over-patching. The plugin adds chrome.runtime because old headless Chrome lacked it. Modern Chrome does not expose a meaningful chrome.runtime on ordinary pages either, unless an extension is involved. So the plugin now hands the detector something a genuine browser would not have. Same story with a plugin list hand-built to mimic the old five-entry PDF viewer array: if the shape drifted upstream and yours did not, you have manufactured an anomaly while trying to erase one.

When someone reports that stealth mode made detection worse, this is usually why. Before you add anything, run your patched browser and a plain, un-automated Chrome side by side against the same probe page and diff the outputs. Anything present in the patched one and absent in the real one is a liability.

The five real reasons it stops working

1. The DevTools Protocol itself is the tell

This is the big one, and it is architectural rather than cosmetic. Puppeteer drives Chromium over the Chrome DevTools Protocol. Enabling certain CDP domains changes observable behaviour inside the page — and pages can look.

The best-known example: when the Runtime domain is enabled, the browser serialises exception and console payloads for the client. If a page constructs an Error object and installs a getter on its stack property, that getter fires when serialisation happens — even though the page's own code never read it. No debugger attached, no getter invocation. Debugger attached, the getter runs. That is a single boolean, obtained in about five lines of script, and it is completely indifferent to how thoroughly you have patched navigator.

Variations exist across other domains, and specific techniques get patched and rediscovered. The durable takeaway is not any one trick. It is this: a JavaScript patch cannot hide a protocol-level side effect, because the side effect happens below JavaScript. You can pile on every evasion ever written and this class of check still resolves correctly.

The practical fix is discipline about which domains you enable. DOM, Input, Page and Network are enough to navigate, find elements, click, type, intercept traffic and screenshot. Runtime is the one to avoid, and Puppeteer's high-level API reaches for it constantly — page.evaluate, page.$eval, waitForFunction, most selector helpers. Driving raw CDP instead of the convenience layer costs you a weekend of plumbing and removes an entire detection category. If you must evaluate script, do it deliberately, once, and know that you have spent the budget.

2. Your identity is inconsistent, not fake

Detection is rarely a single red flag. It is a consistency audit across a few dozen values that ought to corroborate each other. Overriding the user agent string alone is the classic own-goal:

  • You set a macOS UA, but navigator.userAgentData still reports Windows, because User-Agent Client Hints live in a separate structure that a string override does not touch.
  • You claim Chrome 141, but navigator.plugins, the codec support matrix or a newer API surface reflects the build you actually shipped.
  • Your IP geolocates to Frankfurt, Intl.DateTimeFormat().resolvedOptions().timeZone returns America/New_York, and Accept-Language says en-GB. Three answers, three continents.
  • Notification.permission reads denied — the headless default — while navigator.permissions.query reports prompt.

None of these is exotic. All of them are cheap to test and stable to score. If you want the fuller picture of which values get collected and how they are combined, our primer on browser fingerprinting for beginners walks the surface end to end, and the practical guide to changing a browser fingerprint covers the consistency rules that hold a spoofed identity together.

The rule to write on the wall: change everything or change nothing. A partial identity is more suspicious than an honest one, because honest machines are internally coherent and partial spoofs never are.

3. Headless is not the villain, but your GPU might be

People still repeat that headless mode is the problem. It was, when the user agent literally contained HeadlessChrome and the plugin list was empty. Chromium's modern headless mode is the same browser binary with a different windowing path, and most of the historic giveaways are gone.

What is still a giveaway is the environment you run it in. Headless on a cloud VM usually means no GPU, which means WebGL falls back to a software rasteriser and the unmasked renderer string reads something like Google SwiftShader. Real consumer machines almost never report that. The stealth plugin does overwrite the vendor and renderer strings — but it cannot change the capabilities behind them. A page that claims an NVIDIA renderer while failing feature probes and rendering a shader test in software has told you two contradictory things.

The same applies to screen geometry. Headless defaults to 800x600 with a device pixel ratio of 1 and, unless you fix it, outerHeight values that no window manager would produce. Set a plausible resolution, a plausible viewport that accounts for browser chrome, and a device pixel ratio consistent with the device class you claim.

4. The network layer never got the memo

A subtlety most write-ups get backwards. When a page inside Chromium makes a request, the TLS handshake and HTTP/2 frame ordering come from Chromium's own network stack — so your JA3/JA4 fingerprint is genuine and not your problem.

It becomes your problem in three specific situations:

  1. You dropped out of the browser. Extracting cookies and finishing the job with Node's fetch, axios or Python requests hands the server a completely different TLS and header profile than the browser that authenticated. On a monitored endpoint, that mismatch is trivially caught.
  2. Your proxy terminates or rewrites TLS. A misconfigured MITM proxy replaces Chromium's ClientHello with its own. Everything above looks like Chrome; the handshake looks like a Go program.
  3. You mangled the headers. Network.setExtraHTTPHeaders and page.setExtraHTTPHeaders do not always preserve real Chrome's header order and casing, and duplicating a header the browser already sends produces combinations no genuine client emits. Sending a hand-rolled sec-ch-ua that disagrees with the UA string is the most common version of this.

So: do the work inside the browser, keep the proxy transparent at the TLS layer, and add headers only when you know exactly what real Chrome sends on that request type.

5. The IP is carrying more weight than all your JavaScript

Uncomfortable but true: for high-value targets, network reputation frequently outranks everything the page can measure. A perfectly coherent fingerprint on a datacenter ASN that has been hammering the same login endpoint for a year gets challenged. A mediocre fingerprint on a clean residential IP often does not.

This is why so many stealth debugging sessions end in frustration. Hours go into shaving anomalies off the fingerprint while the actual scoring input is the ASN, the request rate from that /24, and the fact that eight hundred other people are running the same scraper through the same provider's exit nodes. If your failures cluster by IP rather than by script version, stop reading fingerprint literature and go read our antidetect browser and residential proxies playbook instead.

A fast diagnostic: run the identical script through three different proxies from three different providers, and through your home connection. If the home connection passes and all three proxies fail, no amount of JavaScript patching will fix this.

A diagnostic order of operations

Do these in order. Each step is cheap and eliminates a whole category, which is the point — most people jump straight to step six and spend a week there.

Step Test If it fails Time
1 Run the same URL by hand in normal Chrome, same machine, same proxy It is the IP or the target's global policy, not your automation 2 min
2 Run your script with the stealth plugin fully disabled If failure is identical, the plugin was never the variable 5 min
3 Diff your patched browser against real Chrome on a probe page Over-patching — remove the extra objects the plugin invents 20 min
4 Comment out every page.evaluate and selector helper that needs Runtime If pass rate jumps, you have a CDP-domain leak 1 hr
5 Check UA vs UA-CH vs timezone vs language vs IP geolocation Inconsistency — fix the whole chain, not one field 30 min
6 Check WebGL renderer, screen size, DPR, device memory, cores Environment tells — usually a GPU-less VM 30 min
7 Swap the proxy pool entirely, keep the script byte-identical Network reputation is the dominant term 1 hr
8 Add human-shaped timing and cursor paths Behavioural scoring on a target that watches sessions 1 day

Steps one and two feel too obvious to bother with. Do them anyway. A meaningful share of the plugin-is-broken reports turn out to be a target that has blocked the whole subnet, or a site that changed its DOM and is now failing for a reason unrelated to detection.

Fixes worth actually implementing

Give every identity a persistent home

Launching with a fresh temporary profile every run means every session is a browser with no history, no cache, no cookies, no Local Storage and no service workers. For scraping public pages, fine. For anything that involves a logged-in account, that is a machine claiming to be a returning user while presenting as a factory reset.

Use a real, persistent --user-data-dir per identity, and keep it. Let it accumulate cache entries and cookies. Let the same account come back to the same profile. This single change does more for account longevity than any fingerprint patch, and it costs nothing but disk.

Make the identity chain coherent, then freeze it

Set these together, from one source of truth, and never randomise them mid-life:

  • User agent string and the full UA-CH metadata (platform, platform version, architecture, brand list, model, mobile flag).
  • Timezone, matched to the proxy exit IP.
  • Accept-Language and navigator.languages, matched to the same region.
  • Screen resolution, available dimensions, device pixel ratio, colour depth.
  • Hardware concurrency and device memory, in combinations real hardware ships (4/8, 8/8, 12/16 — not 3/6).
  • WebGL vendor and renderer, from a pairing that genuinely exists.

And then leave them alone. A profile whose reported CPU count changes between visits is more interesting to a detector than one that reported an unusual value consistently for six months.

Understand what canvas and audio noise should look like

A lot of stealth setups add random noise to canvas readbacks per call. This is worse than doing nothing. Real hardware is deterministic: draw the same shape twice, get identical pixels. If your canvas hash changes between two reads in the same page, you have proven you are spoofing — a check that takes four lines of code and is used widely.

Correct behaviour is a stable per-profile transform: the same seed produces the same deterministic offset for the same input, for the entire life of that profile, and a different one for a different profile. Same principle for audio and for WebGL readbacks.

Move the cursor like a hand attached to an arm

CDP input events are dispatched by the browser itself, so isTrusted is true and there is no synthetic-event tell. What gives you away is the trajectory. page.click(selector) teleports the pointer to the element centre and fires instantly — no approach, no overshoot, no micro-correction, and pixel-perfect centring every single time.

If your target scores behaviour, interpolate movement across several dispatched events, vary the landing point within the element, put a variable pause between focus and the first keystroke, and vary inter-key timing with realistic outliers rather than a uniform random spread. This matters on account-heavy targets and matters much less on plain content scraping — spend the effort where it pays.

When patching stops being the right answer

There is a ceiling here, and it is worth naming honestly. Every JavaScript evasion is a patch applied after the browser has already been built with automation semantics. You are arguing with the runtime from inside it. That works until the check moves below your layer — into the protocol, into the compositor, into the network stack — and then it stops working permanently, not temporarily.

At that point you have three architectural options.

Option A: keep patching, accept the maintenance. Realistic if you own the stack, watch each Chromium release, and your targets are moderately defended. Budget ongoing engineering time forever; this is not a thing you finish.

Option B: patch the browser at build time. Compile a Chromium fork where the fingerprint values are supplied natively in C++ before any page script runs. There is nothing to detect at the JavaScript layer because nothing was overridden there — the values simply are what the engine reports, including inside Web Workers, iframes and any context your injected script would never have reached. This is genuinely the strongest approach and genuinely expensive; a Chromium build is tens of gigabytes and hours of compile time per iteration.

Option C: use a browser someone already built that way. This is what an antidetect browser is, underneath the marketing. If the concept is new to you, start with what an antidetect browser is and how it works.

How the three compare

Stealth plugin Custom Chromium build Antidetect browser
Fingerprint applied Injected JS, per page Native, pre-script Native, pre-script
Reaches Workers and nested frames Unreliably Yes Yes
Survives a Chromium major bump Often breaks You rebuild Vendor rebuilds
Protocol-level CDP leaks Not addressed Addressable Depends on the tool
Per-identity data dirs Roll your own Roll your own Built in
Proxy per identity Roll your own Roll your own Built in
Setup cost An afternoon Weeks An hour
Ongoing cost Continuous High Subscription
Scriptable Yes, natively Yes Varies — check first

The column that decides it for most teams is the last one. An antidetect browser that cannot be driven programmatically is a tool for humans clicking, not for scraping at scale. That is the specific question to ask during any evaluation, and our guide to what to test during a free trial has the rest of the checklist.

Where Dual Login fits

Dual Login was built for exactly the situation this article describes: automation that needs to survive contact with real defences. The fingerprint is applied natively by the engine, read from a signed, encrypted per-profile configuration before a single line of page script executes — so there is no injected JavaScript to detect, no toString masking to get wrong, and no gap between what the main frame reports and what a Web Worker reports.

On the automation side, profiles are driven over raw CDP restricted to the DOM, Input, Page and Network domains. Runtime.enable is not called on the driving path. Clicks and keystrokes are dispatched by the browser and therefore trusted, navigator.webdriver stays false because nothing set it true, and the protocol side effects that give a Puppeteer session away never occur. The default launch path does not hold a CDP client open at all — a persistent attached debugger is itself a signal worth avoiding.

Each profile gets its own persistent data directory and its own optional proxy, so sessions accumulate history the way a real user's browser does and identities never share cookies. If you are running many accounts rather than many scrapes, the same properties matter for a different reason — see the best antidetect browsers for multiple accounts.

What breaks at fifty profiles that did not break at five

Scale introduces failure modes that never show up in a proof of concept.

Correlated identities. Generating profiles from one template with a couple of randomised fields produces a family that shares a rare combination — an unusual font list, an identical audio hash, the same odd screen dimension. Detectors cluster. Fifty accounts that look independent to you can look like one operator to a system comparing across its whole user base. Vary along the axes that real populations vary along, and prefer common values over unique ones. Being unique is the failure state — read the EFF's Cover Your Tracks explanation of entropy if that framing is unfamiliar.

Proxy sharing. Two profiles behind one exit IP at the same moment is a hard link between them. One profile, one IP, or at minimum strict scheduling so their active windows never overlap.

Machine-level collisions. Fifty Chromium instances on one box will have identical GPU capabilities, identical font rendering behaviour and identical timing characteristics unless the engine actively varies them. That is a category the stealth plugin cannot touch, and one of the clearer arguments for a purpose-built engine.

Resource pressure changing behaviour. An overloaded machine produces slow, jerky, obviously-not-human timing and inconsistent rendering. Watch your instance density; the symptoms look like detection and are actually your CPU.

The checklist

Before you conclude the plugin is broken, confirm all of this:

  • The same request succeeds by hand, on the same IP, in a normal browser.
  • Removing the stealth plugin entirely changes the outcome.
  • Your patched browser exposes nothing that real Chrome does not.
  • The Runtime domain is not enabled on your driving path.
  • UA, UA-CH, timezone, language and IP geolocation all agree.
  • WebGL renderer is not a software rasteriser, and the claimed renderer matches actual capability.
  • Canvas and audio outputs are stable within a session and across sessions for the same profile.
  • Each identity has a persistent data directory it keeps.
  • Each identity has its own proxy, and swapping proxy pools changes the outcome.
  • Screen, viewport and DPR describe a device that exists.
  • Timing between actions varies the way a person's does.

If everything on that list is true and you are still blocked, you have hit the architectural ceiling honestly. That is the point to change tools rather than add another evasion.

FAQ

Is puppeteer-extra-plugin-stealth still worth using in 2026?

As a baseline, yes — it removes the trivially detectable defaults and costs nothing. As a strategy against a well-defended target, no. It patches roughly seventeen JavaScript-visible properties and has seen little active maintenance while Chromium ships every four weeks, so some of its patches now create anomalies rather than removing them. Treat it as a floor, not a solution.

Why does my scraper work locally but fail on a server?

Usually two things at once. Your server has no GPU, so WebGL falls back to software rendering and reports a renderer no consumer machine reports. And your server's IP belongs to a datacenter ASN with a poor reputation, while your home connection is residential. Test both variables independently: run headful with a GPU on the server, and run the server's script through a residential proxy, and see which one moves the needle.

Can a website detect Puppeteer even if navigator.webdriver is false?

Yes, easily. navigator.webdriver is one boolean among dozens, and the more interesting checks operate below JavaScript — protocol side effects from enabled CDP domains, timing characteristics, capability mismatches, and network reputation. Setting it to false in 2026 is table stakes, not a defence.

Does switching from headless to headful fix detection?

Sometimes, but not for the reason people assume. Headful on a machine with a real GPU fixes the WebGL and rendering tells, which is the actual benefit. Modern headless mode is otherwise the same browser binary. If headful fixes your problem, the cause was the graphics environment, not the visibility of the window.

Should I randomise the fingerprint on every run?

No. Per-run randomisation is a strong signal by itself: real devices do not change their CPU count, screen size or canvas hash between visits. Generate one coherent identity per profile, persist it alongside that profile's cookies and cache, and keep it stable for the profile's entire life.

Is an antidetect browser overkill for plain web scraping?

For public pages behind no meaningful defence, yes — plain Puppeteer with sane rate limiting is the right tool. It becomes worth it the moment sessions, logins or persistent identities are involved, or when you are running enough parallel identities that correlation between them becomes the risk. If cost is the deciding factor, compare options in our guides to cheaper Multilogin alternatives and antidetect browsers for small teams.

Wrapping up

The frustrating thing about stealth debugging is that the loudest advice targets the cheapest signals. Everyone knows about navigator.webdriver; almost nobody checks whether their canvas output is stable across two reads in the same page, or whether the convenience method they called quietly enabled a protocol domain that a page can observe.

Work the list in order. Eliminate the IP first because it is free to test and frequently the answer. Eliminate over-patching second, because it is the most common self-inflicted wound. Then go after the protocol layer, then the identity chain, then the environment. Most sessions end within the first three steps.

And if you finish the list and the ceiling is real, the honest fix is a browser whose identity was built in rather than bolted on. That is what Dual Login is — native fingerprints applied before page script runs, isolated data directories and proxies per profile, and raw CDP automation that never enables the domains that give a session away. Spin up a few profiles, point your existing script at them, and compare the pass rate against the setup you have been fighting with. The comparison takes an afternoon and settles the argument.

Run every account like a separate device

Dual Login gives each profile a real fingerprint, its own proxy and sealed storage — free plan, no card required.