Puppeteer Mobile Proxy Setup, Including Authenticated Proxies
Key takeaways
--proxy-serverdoes not accept credentials inline. Chromium parses the host and port and discards the username and password, which is the single most common Puppeteer proxy failure.- Authentication goes through
page.authenticate({ username, password }), which answers the proxy's 407 challenge. Call it before the first navigation, and call it on every page — it is per-page, not per-browser. --proxy-serveris browser-wide. Every page in that browser shares one exit. Recent Puppeteer versions expose a per-context proxy option if you need different exits in one process.- Chromium resolves DNS locally when you give it a SOCKS5 proxy. Your resolver sees every hostname while your TCP connections come from the proxy — a leak that also produces CDN misroutes. An HTTP proxy has no such mode:
CONNECTcarries the hostname. - Chromium does not support SOCKS5 username and password authentication at all. If your proxy requires credentials on SOCKS5, use the HTTP port instead — there is no flag that fixes this.
- Concurrency is what developers get wrong. A browser is heavy and a mobile exit is one radio with one uplink. A handful of pages, not a pool of thirty — this is a property of the device, not of Puppeteer.
- Rotation is a physical radio re-attach costing roughly seven seconds. Navigations in flight will fail; retry with a short backoff rather than immediately.
Almost every Puppeteer proxy problem is the same problem. Someone writes --proxy-server=http://user:pass@host:port, Chromium reads the host and port, throws the credentials away without a word, and every request comes back 407 Proxy Authentication Required with credentials that are completely correct.
That gap is the whole story, and after it come the SOCKS5 and DNS behaviours Chromium handles unlike any command-line tool, plus the concurrency question.
The setup that works, and the one that never will
Proxy configuration in Puppeteer is two separate steps that look like one. The launch argument tells Chromium where the proxy is. page.authenticate() tells it who you are. Credentials in the launch argument are discarded. Both steps are required, and the second one is per-page.
import puppeteer from 'puppeteer';
const PROXY_HOST = 'proxy.example.com';
const PROXY_PORT = 30001;
const PROXY_USER = 'your_http_user';
const PROXY_PASS = 'your_http_pass';
const browser = await puppeteer.launch({
headless: true,
args: [
// Host and port only. Credentials here are silently ignored.
`--proxy-server=http://${PROXY_HOST}:${PROXY_PORT}`,
'--proxy-bypass-list=<-loopback>',
],
});
const page = await browser.newPage();
// This is the step people miss. It answers the proxy's 407 challenge.
await page.authenticate({ username: PROXY_USER, password: PROXY_PASS });
page.setDefaultNavigationTimeout(90_000);
const res = await page.goto('https://api.ipify.org', {
waitUntil: 'domcontentloaded',
});
console.log(res.status(), await page.evaluate(() => document.body.innerText));
await browser.close();
--proxy-bypass-list=<-loopback> stops Chromium routing localhost around the proxy. waitUntil: 'domcontentloaded' avoids waiting for every asset over a cellular uplink. And the api.ipify.org fetch is the verification step — if it does not print the exit address you expect, nothing downstream is worth debugging.
Credentials passed to page.authenticate() are taken literally, with no percent-decoding, so a password containing @, : or % needs no escaping. One encoding trap that produces phantom 407s does not exist on this path.
Why the launch flag ignores your credentials
This is deliberate Chromium behaviour, not a Puppeteer bug. --proxy-server is parsed as a proxy location, and Chromium assumes a human will answer the authentication dialog when a proxy challenges. Headless, nobody answers, so the request 407s and the browser waits for a prompt that will never be filled in.
page.authenticate() is the programmatic answer to that dialog, covering both origin-server 401 and proxy 407 challenges. Two properties catch people out.
It is per-page. A script that authenticates its first page and then calls browser.newPage() finds the second page collecting 407s while the first works perfectly. The same applies to popups and to any page from a target event.
It must precede the navigation that triggers the challenge. Call it after page.goto() and the first request has already failed.
A small helper removes both failure modes:
async function newProxiedPage(browser, creds) {
const page = await browser.newPage();
await page.authenticate(creds);
page.setDefaultNavigationTimeout(90_000);
return page;
}
Still seeing 407 after that, and the remaining causes are ordinary: the credentials belong to another protocol's port — some providers issue separate pairs for SOCKS5 and HTTP, or the account is in IP-allowlist mode and your egress address changed. Both in the 407 page.
SOCKS5 in Chromium: no authentication, and local DNS
Chromium accepts socks5:// in --proxy-server, so unauthenticated SOCKS5 works. Two things then differ from every command-line tool you have used. Chromium has no support for SOCKS5 username and password authentication, and it resolves destination hostnames locally rather than passing them to the proxy.
The authentication limit is absolute: page.authenticate() answers HTTP proxy challenges, while SOCKS5 authenticates during its own handshake, which the browser has no mechanism for. No flag or API fixes it — use the HTTP proxy port.
The DNS behaviour quietly costs you results. Chromium looks the hostname up itself and hands the proxy an address, so your own resolver sees every domain you visit while the traffic exits elsewhere. And because large sites answer DNS based on where the query came from, you get a CDN edge chosen for your real location while the connection arrives from a carrier network somewhere else — slow connects, and content served for the wrong region.
The documented workaround is a host-resolver rule that prevents local resolution, so names pass to the SOCKS server instead:
args: [
`--proxy-server=socks5://${PROXY_HOST}:${PROXY_PORT}`,
// Stop Chromium resolving destinations locally; allow the proxy host itself.
`--host-resolver-rules=MAP * ~NOTFOUND , EXCLUDE ${PROXY_HOST}`,
]
It works, and it is fiddly enough that the simpler answer usually wins: use the HTTP proxy port, where CONNECT example.com:443 carries the hostname by definition and there is no resolution mode to get wrong. On a mobile proxy the lookup then happens on the phone, over the carrier's resolvers. Full comparison in SOCKS5 vs HTTP proxies; hostnames failing instantly while literal IPs succeed is a device-side fault.
Rule of thumb for Chromium: HTTP proxy when you need authentication or correct DNS, SOCKS5 only when you have neither requirement.
Per-context proxies and several exits in one process
--proxy-server is a browser-wide argument. Every page in that instance uses the same exit, which is the right default for one identity and useless when you want several. The reliable way to run different exits is one browser per proxy, and recent Puppeteer versions also expose a proxy option when creating a browser context.
The browser-per-proxy shape works everywhere:
async function launchWithProxy({ host, port, username, password }) {
const browser = await puppeteer.launch({
headless: true,
args: [`--proxy-server=http://${host}:${port}`],
});
browser.on('targetcreated', async (target) => {
const page = await target.page();
if (page) await page.authenticate({ username, password });
});
return browser;
}
That targetcreated handler catches popups and pages your script did not open — otherwise the pages that mysteriously 407.
Newer Puppeteer releases accept a proxy server when creating a browser context, letting one process hold several exits. Check the signature against your installed version rather than a blog post; this surface has changed names between major versions. Authentication is unaffected — still per page.
Whether you should run several exits at once is separate. Multiple parallel identities from one host is itself a pattern, and if each carries a login, fewer and longer-lived sessions usually serve you better — the argument in dedicated vs rotating proxies.
Concurrency: a browser farm on one radio
A headless browser is the heaviest client you can point at a proxy — dozens of requests per page load, all at once. Route several through one mobile exit and they contend for one radio and one uplink. Parallelism does not add capacity; it splits it, and past a point degrades the device. That is hardware, not Puppeteer.
Three mechanisms stack, and they present differently enough to separate.
The uplink is a hard ceiling. Roughly 2-3 MB/s per device, set by the cellular upload path — everything the destination returns must be pushed up from the phone before it reaches you, and carriers allocate far less capacity upward than downward. The 5G downlink figure describes the opposite direction.
Heavy fan-out can wedge the device. All of your concurrent connections share one multiplexed tunnel, and under very heavy fan-out that multiplexer can wedge: the phone goes quiet while still appearing connected, and a watchdog recovers it in about three minutes. The trigger is simultaneous streams, not request rate.
Thirty parallel connections is not a shape a phone produces. Even when it works it is a behavioural signal on top of everything else the destination scores, and a reliable way to collect 429s.
The practical shape: a handful of pages per device rather than a pool, real pauses between navigations, and more devices when you need more done. Cutting requests per page helps as much as cutting pages, because asset fetching is most of the traffic:
await page.setRequestInterception(true);
page.on('request', (req) => {
const type = req.resourceType();
if (type === 'image' || type === 'media' || type === 'font') {
req.abort();
} else {
req.continue();
}
});
Blocking images, media and fonts removes most of the bytes on a typical page load. On a constrained uplink that is the highest-leverage change available, and it cuts the number of simultaneous streams the device has to juggle. Slow speeds in more depth.
Retry, Retry-After, and the seven-second rotation gap
Two waits matter in browser automation and they are different. A 429 tells you when to come back, in a header most clients throw away. A rotation is a physical radio re-attach taking about seven seconds regardless of what your code wants. Both need a backoff; neither wants an immediate retry.
page.goto() returns the response, so the header is readable directly:
const MAX_WAIT_MS = 120_000;
function retryAfterMs(res) {
const raw = res?.headers()?.['retry-after'];
if (!raw) return null;
if (/^\d+$/.test(raw.trim())) return Number(raw.trim()) * 1000;
const when = Date.parse(raw); // HTTP-date form
return Number.isNaN(when) ? null : Math.max(0, when - Date.now());
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function gotoWithBackoff(page, url, attempts = 4) {
for (let i = 0; i < attempts; i++) {
try {
const res = await page.goto(url, { waitUntil: 'domcontentloaded' });
if (res && (res.status() === 429 || res.status() === 503)) {
const wait = retryAfterMs(res) ?? Math.min(MAX_WAIT_MS, 2000 * 2 ** i);
await sleep(Math.min(wait, MAX_WAIT_MS));
continue;
}
return res;
} catch (err) {
// Navigation errors also cover the rotation window: the radio is
// re-attaching and nothing will connect for several seconds.
if (i === attempts - 1) throw err;
await sleep(Math.min(MAX_WAIT_MS, 3000 * 2 ** i));
}
}
}
Retry-After arrives in two formats — a delay in seconds, or an HTTP-date, and a parser handling only integers breaks on the second. Honouring it is the highest-value change against a 429: retrying inside the window the server just named is how a short throttle becomes a long one.
For rotation the number to design around is seven seconds — a real airplane-mode cycle on a handset, almost all of it carrier-side radio work. Code that rotates mid-run should expect the next navigation to fail, wait, and try again. A rotation returning success in roughly two seconds did not happen; that is a wedged radio.
Symptom, cause, fix
Puppeteer proxy failures cluster into a short list, and almost all of them are separable by symptom without reading a single log line. Find yours below before changing configuration, because the expensive mistake is rewriting your launch arguments in response to a fault that lives on the device rather than in your code.
| Symptom | Cause | Fix |
|---|---|---|
| Every request 407, credentials correct | Credentials passed inline to --proxy-server, which discards them | Call page.authenticate() — 407 explained |
| First page works, a second 407s | authenticate() is per-page, called once | Authenticate every page, including popups |
| 407 on the first navigation only | authenticate() called after goto() | Authenticate before navigating |
| SOCKS5 rejects credentials | Chromium has no SOCKS5 authentication | Use the HTTP proxy port |
| Site behaves as if you are elsewhere | DNS resolved locally over SOCKS5 | HTTP port — SOCKS5 vs HTTP |
| Hostnames fail instantly, literal IPs work | Stale resolver state on the device | SOCKS5 hostname failures |
| Navigation timeouts under load | Too many pages sharing one radio | Fewer pages; block images and fonts |
| Pool stalls ~3 minutes, then recovers | Multiplexer wedged under fan-out | Reduce concurrency; recovery is automatic |
| Pages load at 2-3 MB/s and no faster | Normal — cellular uplink ceiling | Nothing; slow speeds |
| Everything fails briefly after rotating | Radio re-attaching, ~7 seconds | Short backoff, then retry |
| Exit IP unchanged after a rotation | Wedged radio — toggle never acted on | Rotation diagnosis |
| No connection, no status code | Wrong port or protocol | Proxy not working |
When this becomes an infrastructure problem
Everything above is code you control. The line is crossed when the setup is correct — authenticated per page, HTTP port, concurrency low, Retry-After honoured, and behaviour still degrades, because the remaining variable is the exit device and Puppeteer cannot see it.
On a shared pool you cannot tell your own contention from someone else's: a stall could be your page count or another tenant's scraper on the same handset, and no measurement separates them. On a dedicated device your automation is the only traffic on that radio, so page count maps to behaviour in a way you can reason about, and rotation is an endpoint you call rather than a favour you request. That is what QuantumProxy sells — one real 4G/5G device per customer, both protocols exposed, rotation as an API. Mechanics in how mobile proxies work.
The honest limits. A mobile exit does not make a headless browser undetectable — address reputation is one input, and the automation-flavoured surface headless Chromium presents is a bigger one. It does not lift the throughput ceiling, and it does not fix a quota keyed to your account rather than your address.
The check to run first
Before touching Puppeteer configuration, prove the proxy itself is healthy from the same machine. Comparing a browser against a client with no launch arguments, no authentication hooks and no page lifecycle removes every Puppeteer-shaped variable at once, which is why this is worth doing before you read a single line of your own code.
Runcurl -v --proxy http://host:port --proxy-user 'user:pass' https://api.ipify.organd read the exit address. If curl works and Puppeteer does not, the proxy is fine and the fault ispage.authenticate()— its ordering, its scope, or its absence.
If curl also fails, the problem is transport or credentials and Puppeteer is the wrong place to look — start with proxy not working. If both work and the target still challenges you, the transport was never the issue. The same constraints in other stacks: Playwright and Scrapy.
Frequently asked questions
How do I use an authenticated proxy in Puppeteer?
Pass the proxy host and port with the --proxy-server launch argument, then call page.authenticate({ username, password }) on each page before you navigate. Credentials embedded in the --proxy-server value are ignored by Chromium, so the launch argument alone will produce a 407 on every request. The two steps are separate on purpose.
Why does --proxy-server with user:pass@host:port not work?
Chromium deliberately ignores userinfo in that flag. It expects a human to answer the browser's authentication dialog instead, and in headless mode nobody does, so every request fails with 407 Proxy Authentication Required. Puppeteer's page.authenticate() exists precisely to answer that challenge programmatically.
Does Puppeteer support SOCKS5 proxies?
Chromium accepts a socks5:// value in --proxy-server, so unauthenticated SOCKS5 works. Authenticated SOCKS5 does not — Chromium has no mechanism for SOCKS5 username and password authentication, and page.authenticate() only answers HTTP proxy challenges. If your provider requires credentials, use the HTTP proxy port.
Does Puppeteer leak DNS through a SOCKS5 proxy?
By default yes. Chromium resolves destination hostnames locally and hands the SOCKS proxy an address, so your own resolver sees every domain you visit and the address it returns is chosen for your real location rather than the proxy's. The documented workaround is a host-resolver rule that stops local resolution, and the simpler answer is to use the HTTP proxy port, where CONNECT carries the hostname.
How many Puppeteer pages can I run through one mobile proxy?
Fewer than you would through a datacenter proxy. A mobile exit is one handset with one radio and one uplink capped around 2-3 MB/s, so parallel pages split a fixed resource instead of adding capacity. Very heavy parallel fan-out can also wedge the device's connection multiplexer, which takes a few minutes to recover. A small number of pages with real pauses is the correct shape.
Do I need to call page.authenticate on every page?
Yes. It is a per-page setting, so a new page, a popup, or a page opened by a target event all need their own call before their first navigation. A common failure is a script that authenticates the initial page and then opens a second one, which starts collecting 407s while the first page works perfectly.
Related reading
- Playwright mobile proxy configuration: contexts, SOCKS5 and concurrency
- Scrapy + mobile proxies: middleware, rotation and retry handling
- HTTP 407 Proxy Authentication Required: causes and fixes
- SOCKS5 vs HTTP proxies: which should you use with mobile?
- HTTP 429 Too Many Requests: Retry-After, backoff and rate limit types
- GoLogin mobile proxy setup: configuration that actually connects
- Dolphin Anty mobile proxy configuration guide
Dedicated mobile proxies, one dashboard
Real 4G/5G devices on US carrier SIMs. Sticky IP per customer, rotation on demand.
See plans