How to Use a Mobile Proxy with Puppeteer in Node.js (Auth Fix) 2026
How to Use a Mobile Proxy with Puppeteer in Node.js (Auth Fix) 2026
You pass the proxy server flag to puppeteer, point it at your mobile proxy, launch chrome, and it just sits there. No error, no crash, just a hang until navigation times out. What happened is chrome hit a native authentication dialog, the little login box the operating system draws, and that box lives outside the page and outside the dom, so your script has nothing to click and nothing to type into. It waits forever. Almost every puppeteer proxy tutorial gets this fix wrong, and the real fix is one line.
I run a hardware mobile proxy farm in Singapore. Real phones, real sim cards from Singtel, M1, and StarHub. I drive puppeteer through these proxies for real jobs, so this is the setup that holds in production, not the one that works on your laptop and dies on a server.
Why chrome hangs instead of erroring
The hang comes from where you put the credentials, not from a bad proxy. When you call puppeteer.launch, you pass an args array, and in it you set the proxy server flag to your host and port. Just the host and port. No username, no password. That flag does not accept credentials, and that is the whole source of the problem. Chrome now knows where the proxy is but not who you are. So when the proxy challenges for auth, chrome raises the native dialog, and you hang.
const browser = await puppeteer.launch({
headless: "new",
args: ["--proxy-server=HOST:PORT"],
});
That launch is correct, as far as it goes. The piece everyone leaves out is the next one.
The one-line fix: page.authenticate
The fix is page.authenticate, called per page before you navigate. After you create a page from the browser, and before any navigation, you call page.authenticate and pass an object with your username and password.
const page = await context.newPage();
await page.authenticate({ username: "USER", password: "PASS" });
That method hooks the authentication challenge at the protocol level and answers the 407 for you, without ever drawing the native dialog. You do not need an extension, you do not need a local upstream proxy, you do not need to fight the operating system. You do need to call it on every page you open, because it is per page, not per browser. That is the part people miss. They call it once, then wonder why the second tab hangs.
Verify the exit ip before you trust anything
Always confirm the exit ip on the first navigation, because assuming is how you lose an afternoon. After authenticate and after you navigate, hit an ip echo endpoint, read the body, and log it.
await page.goto("https://api.ipify.org?format=json");
const body = await page.evaluate(() => document.body.innerText);
console.log(body);
If the ip you see is your mobile exit ip, you are tunneling correctly. If it is your own machine ip, the proxy flag did not take, usually a typo in the host or a missing protocol prefix. Check that first, before you touch anything else. The same idea applies whether you drive the browser with puppeteer or hit endpoints directly: verify the carrier ip once at the start and you save yourself a whole run that quietly used your own address.
One context per account, not ten tabs
If you run more than one account, give each its own browser context, not its own tab. Cookies, storage, and cache bleed across tabs in the same context, so ten tabs in one browser is not isolation. Use createBrowserContext to get a fresh isolated context per account.
const context = await browser.createBrowserContext();
const page = await context.newPage();
await page.authenticate({ username: "USER", password: "PASS" });
Each context gets its own cookie jar. Pair one context with one sticky exit ip and one identity. The ip and the cookies should live and die together, the same rule instagram multi-account setups follow, because a session cookie tied to one ip that suddenly appears on a different ip is the loudest possible tell.
The trap is that the proxy and the user agent are set per page, not per context. So if you open a second page inside account one’s context and forget to authenticate it or forget to set its identity, that page leaks. The discipline is simple: every new page gets the same authenticate call, the same user agent, the same viewport, the same exit ip as the rest of its context. Wrap that in a helper function so you cannot forget. One function that takes a context and returns a fully configured page.
Kill the webrtc leak
By default chrome can use webrtc to discover your real local and public ip and hand it to a page through a javascript api, completely bypassing the proxy. So the page sees your mobile proxy ip in the headers and your real ip through webrtc, at the same time. You have to shut that path.
args: [
"--proxy-server=HOST:PORT",
"--force-webrtc-ip-handling-policy=disable_non_proxied_udp",
"--disable-features=WebRtcHideLocalIpsWithMdns",
]
Force the webrtc ip handling policy to disable non-proxied udp so chrome never exposes an ip that did not come through the tunnel. Then test it. There are public webrtc leak test pages, so load one through your puppeteer browser and confirm it shows only the proxy ip, nothing local.
Match the device identity to the network
You are leaving from a mobile carrier ip, so the rest of the request should look mobile. Set a mobile user agent, a recent chrome on android string, and set the viewport to a phone size with a device scale factor and the isMobile flag true. Puppeteer has an emulate helper for known devices, so use it.
const { KnownDevices } = require("puppeteer");
await page.emulate(KnownDevices["Pixel 5"]);
A desktop user agent and a 1500-pixel viewport riding a Singtel mobile ip is a contradiction, and contradictions get flagged. The proxy gives you a mobile network identity, so you owe it a matching mobile device identity.
Handle failures the way mobile proxies actually fail
Mobile proxies fail differently than datacenter, so handle the failures on purpose. A connection reset mid-navigation usually means the tower rotated and dropped your tunnel. Catch the navigation error, wait a moment, and retry, rather than letting the whole script die.
try {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60000 });
} catch (err) {
await new Promise((r) => setTimeout(r, 2000));
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60000 });
}
A 407 that slips through means authenticate was not called on that page, so fix the ordering. And a soft block, a captcha or a verify page that comes back as a normal 200, will not throw at all, so you have to check the page content for the block markers and react, not just trust the status code. This is why a mobile ip matters: a real Singtel, M1, or StarHub ip hits far fewer soft blocks than a datacenter range in the first place.
Timeouts, dns, and resource blocking
Raise the navigation timeout to 60 seconds, because a mobile proxy adds real latency over the radio. The default 30 seconds is usually fine, but a slow tower or a heavy page can blow past it, and then you get a timeout that looks like a proxy failure but is really just a slow phone. Treat a timeout as a retry candidate, not a hard failure, and prefer domcontentloaded or networkidle deliberately so you do not stall waiting for every last asset.
Dns is the quiet leak. Node and chrome may resolve hostnames locally before connecting through the proxy. The rule is the same everywhere: the name lookup should happen where the traffic exits, not on your box. Verify with a dns leak test page through the browser and confirm the resolver belongs to the carrier path, not your isp.
Resource blocking helps more on mobile than anywhere, because you are paying for bandwidth over a metered path. Intercept requests and abort the ones you do not need.
await page.setRequestInterception(true);
page.on("request", (req) => {
const type = req.resourceType();
if (["image", "media", "font"].includes(type)) req.abort();
else req.continue();
});
Block conservatively, test, then tighten, because a page that loads with zero images when a real browser would load them is itself a tell on a few aggressive sites.
The production checklist
Call authenticate before the first navigation, never after, because if you navigate first the challenge fires before your credentials are registered and you get the exact hang you were avoiding. Order is authenticate, set identity, then navigate.
Here is the whole backbone in one place:
- Launch with the proxy server flag for host and port, plus the webrtc flags.
- Create a browser context per account for isolation.
- Create a page, then call
page.authenticatewith your username and password. - Emulate a mobile device so the user agent and viewport match the ip.
- Navigate to an ip echo, read and log the exit ip to prove the tunnel.
- Wrap real navigation in a try-catch that retries on a reset.
- Close the context when the account is done, so cookies go with it.
Log the exit ip on the first navigation of every run so your logs can prove which ip did what when a site blocks you. And close contexts when you are finished with an account, because a long-running script that opens a fresh context per task without closing the old ones will eat memory until chrome falls over. Treat each context like a disposable phone. Use it, then put it away, and the next account starts genuinely clean.
Run it on real Singapore mobile ips
If you want this to just work, the proxies under it have to be real. This is what I run: real Singapore mobile ips on actual Singtel, M1, and StarHub sims, sticky sessions you can hold per account for the life of a session, and dns that routes through the tunnel so you stop leaking your real resolver while puppeteer drives. Start a free trial of Singapore Mobile Proxy and use code YT30 on signup. Point your launch args at it, call authenticate, kill webrtc, and you have a browser that is genuinely on a mobile ip.
Get new guides and videos first — join the Telegram channel.