Puppeteer routes through a proxy with a single launch flag, and then hits one wall that stops most people: Chromium's --proxy-server argument does not accept a username and password, so the moment your proxy needs authentication the obvious approach silently fails. This guide covers proxies with Puppeteer end to end: the launch argument, authenticated proxies with both page.authenticate and the proxy-chain package, giving different pages different exits, rotating a pool, and verifying the exit IP so you know it actually took effect.
We run a proxy network and a live proxy checker, so the Puppeteer proxy question we answer most is why an authenticated proxy appears to be ignored. The answer is almost always the credentials-in-the-URL trap below. Every example runs on Puppeteer 22 or newer. Where one needs a live proxy, pull a fresh one from our free proxy API, which returns recently checked endpoints with no key.
How do you set a proxy in Puppeteer?
Pass --proxy-server as a launch argument. It applies to the whole browser, so every page opened from that instance uses the same exit:
import puppeteer from "puppeteer";
const browser = await puppeteer.launch({
args: ["--proxy-server=http://203.0.113.7:8080"],
});
const page = await browser.newPage();
await page.goto("https://httpbin.org/ip");
console.log(await page.evaluate(() => document.body.innerText));
await browser.close();
The scheme in the flag can be http://, https://, or socks5://. A SOCKS5 proxy needs no extra package on the Chromium side, which is one way Puppeteer is simpler than the Python clients.
The authentication trap
The instinct from every other tool is to put the login in the URL, user:pass@host:port. Chromium ignores the userinfo part of --proxy-server and connects unauthenticated, so the proxy answers with 407 Proxy Authentication Required and your navigation fails. This is the single most common Puppeteer proxy bug.
The built-in fix is page.authenticate, called before the navigation that triggers the challenge:
const browser = await puppeteer.launch({
args: ["--proxy-server=http://203.0.113.7:8080"],
});
const page = await browser.newPage();
await page.authenticate({ username: "user", password: "pass" });
await page.goto("https://httpbin.org/ip");
page.authenticate works, but it is per page, so a script that opens many tabs has to call it on each one, and it interacts awkwardly with request interception. For anything beyond a single page, the sturdier answer is proxy-chain.
user:pass@host in --proxy-server
Chromium drops the login
Proxy returns 407
navigation fails
page.authenticate
answers 407, per page
proxy-chain
local proxy holds the login
Authenticated proxies with proxy-chain
The proxy-chain package starts a local proxy that carries your credentials and forwards to the upstream, so Chromium points at a plain http://127.0.0.1:PORT with no auth and never sees the 407:
import puppeteer from "puppeteer";
import { anonymizeProxy } from "proxy-chain";
const upstream = "http://user:pass@203.0.113.7:8080";
const local = await anonymizeProxy(upstream); // -> http://127.0.0.1:xxxxx
const browser = await puppeteer.launch({ args: [`--proxy-server=${local}`] });
const page = await browser.newPage();
await page.goto("https://httpbin.org/ip");
// ... work ...
await browser.close();
// closeAnonymizedProxy(local, true) when you are fully done
This is the pattern to standardise on. It handles auth once, works across every page in the browser, and gives you a place to swap the upstream when you rotate.
Different exits per page
There is no per-page proxy from a single browser, because --proxy-server is fixed at launch for the whole Chromium process. Two patterns get you different exits:
- One browser per proxy. Launch a separate browser for each exit and open a batch of pages inside it. This is the simplest and most reliable, at the cost of a Chromium process per proxy.
- Browser contexts.
browser.createBrowserContext()isolates cookies and storage, and on recent versions accepts aproxyServeroption so a context can carry its own exit. It is lighter than a whole browser but check it behaves on your Puppeteer version before relying on it.
For most rotation jobs, one browser per proxy reused for a batch of pages is the pattern that does not surprise you.
Rotating a pool
Because the proxy is set at launch, rotation means picking an exit per browser and retrying on a different one when a page fails:
import puppeteer from "puppeteer";
import { anonymizeProxy, closeAnonymizedProxy } from "proxy-chain";
const POOL = [
"http://user:pass@203.0.113.7:8080",
"http://user:pass@203.0.113.24:3128",
"http://user:pass@198.51.100.14:8080",
];
async function scrape(url, tries = 3) {
for (let i = 0; i < tries; i++) {
const upstream = POOL[Math.floor(Math.random() * POOL.length)];
const local = await anonymizeProxy(upstream);
const browser = await puppeteer.launch({ args: [`--proxy-server=${local}`] });
try {
const page = await browser.newPage();
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
return await page.content();
} catch (err) {
// dead or blocked exit, fall through to the next proxy
} finally {
await browser.close();
await closeAnonymizedProxy(local, true).catch(() => {});
}
}
throw new Error(`all ${tries} proxies failed for ${url}`);
}
The load-bearing parts are the same as any rotation loop: a fresh exit per attempt, a real navigation timeout so a silent proxy cannot hang the page, and always closing the browser and the local proxy in a finally so a failed attempt does not leak a Chromium process. You do not have to maintain the list by hand; our free proxy API returns a fresh pool you can load at startup and split into POOL.
Verify the exit IP
Never assume the flag took effect. Load an IP echo and confirm the address is the proxy's, not yours:
const page = await browser.newPage();
await page.goto("https://httpbin.org/ip");
const seen = JSON.parse(await page.evaluate(() => document.body.innerText)).origin;
console.log("exit IP:", seen);
If that address is your own, the proxy did not apply, which for an authenticated proxy almost always means the credentials were in the URL instead of in page.authenticate or proxy-chain.
Where to go from here
Puppeteer's proxy story is one flag and one gotcha. Get --proxy-server and the authentication trap right and the rest is the discipline every headless scraper needs: verify each exit, bound your concurrency, and rotate a pool that is actually alive. A real browser also leaks more than an IP, so pair the proxy with the fingerprint and behaviour hygiene in how to avoid IP bans while scraping, and note that a datacenter IP behind a real browser is still a datacenter IP to any site that checks.
For verifying exits, our proxy checker reports exit IP, anonymity grade, geolocation and latency in one paste. For the pool, the free proxy API hands you recently checked endpoints with no key, ideal for testing this code before a paid gateway. The Playwright guide is the sibling reference if you run the other headless library, and proxies for Puppeteer covers which proxy type each job needs. When launching a browser per proxy becomes the bottleneck, a rotating gateway that returns a fresh residential IP per request at $0.44/GB lets one browser reuse a single endpoint while the exit still changes underneath it.