You add --proxy-server=http://user:pass@host:port to the launch arguments, the way you would write a proxy for cURL, and the first page.goto() stops with net::ERR_NO_SUPPORTED_PROXIES. You take the credentials out and the error becomes net::ERR_INVALID_AUTH_CREDENTIALS. Puppeteer has no proxy option of its own. It starts Chrome, and Chrome decides how a proxy is used, so most answers sit in Chrome's rules rather than in Puppeteer's API.
This post covers the launch flag and page.authenticate(), a proxy per browser context, sticky and rotating sessions, SOCKS5, the bypass list, checking the exit IP, the errors you will meet and a complete script with retries. Every example ran on 6 October 2026 with Puppeteer 25.12.0 and the Chrome 154 build it downloads, against a local HTTP proxy that requires a username and password and a local SOCKS5 server. The error messages quoted are the ones we got.
How does Puppeteer send traffic through a proxy?
Puppeteer is a Node.js library that drives a real browser from code. It does not open network connections itself; the browser does. A proxy is therefore a Chrome setting, given either as a command-line flag when the browser starts or as an option when a browser context is created. A browser context is an isolated session inside one browser, with its own cookies, cache and storage, much like an incognito window.
Logging in to the proxy is a separate step, because Chrome does not read credentials from the proxy address. Our test proxy logged this sequence for one HTTPS page:
- Chrome sends
CONNECT httpbin.org:443, asking the proxy to open a tunnel to the site. For anhttp://page it sends the request itself. - The request carries no credentials, so the proxy answers
407 Proxy Authentication Required. RFC 9110 defines 407 as the proxy's counterpart of 401. - Puppeteer catches that challenge and answers with the username and password given to
page.authenticate(). - Chrome repeats the request with a
Proxy-Authorizationheader, the proxy replies200, and the encrypted connection to the site is built inside the tunnel. The proxy sees the host name, not the page content. - Chrome stores the credentials in the context's authentication cache and sends them at once on later connections.
Step 5 is why credentials end up belonging to the context, as the tests below show.
How do you set a proxy with --proxy-server and page.authenticate()?
npm i puppeteer installs the library and downloads a matching Chrome; puppeteer-core is the same API without the download. The minimal setup:
import puppeteer from "puppeteer";
const browser = await puppeteer.launch({
args: ["--proxy-server=http://pr.proxynet.io:8000"],
});
const page = await browser.newPage();
await page.authenticate({ username: "user", password: "pass" });
await page.goto("https://httpbin.org/ip");
console.log(await page.$eval("body", (el) => el.innerText)); // the exit IP the site sees
await browser.close();The http:// in the flag describes the connection to the proxy, not the pages. An HTTP proxy carries https:// sites through the tunnel, and their content stays encrypted between Chrome and the site. The page.authenticate() reference adds that Puppeteer turns on request interception behind the scenes for this, which can cost some performance; passing null switches it off.
Three things went wrong in our tests, each with a fixed rule:
- Call
page.authenticate()before the firstgoto(). Called afterwards, the first navigation failed withnet::ERR_INVALID_AUTH_CREDENTIALS; only the next one went through. - Keep credentials out of the flag. With
user:pass@in the address, Chrome 154 rejected the whole value withnet::ERR_NO_SUPPORTED_PROXIES. Older guides describe a407here; today the request never reaches the proxy. - Do not send
Proxy-Authorizationyourself. Setting it withpage.setExtraHTTPHeaders()made Chrome refuse the navigation withnet::ERR_INVALID_ARGUMENT.
--proxy-server value | Result in Chrome 154 |
|---|---|
http://pr.proxynet.io:8000 | Works; HTTPS pages go through a CONNECT tunnel |
pr.proxynet.io:8000 | Works; without a scheme Chrome assumes an HTTP proxy |
http://user:pass@pr.proxynet.io:8000 | net::ERR_NO_SUPPORTED_PROXIES |
socks5://host:port | Works only when the server asks for no password |
socks5h://host:port | net::ERR_NO_SUPPORTED_PROXIES; Chrome does not know this scheme |
How do you use a different proxy for each browser context?
Puppeteer 22 renamed createIncognitoBrowserContext(), the name older guides still use, to browser.createBrowserContext(). Its BrowserContextOptions carry two proxy fields, proxyServer and proxyBypassList. The browser can start without a proxy while each context gets its own:
import puppeteer from "puppeteer";
const PROXY = "http://pr.proxynet.io:8000";
// One sticky session per context: same session ID -> same exit IP for up to 600 seconds
const sessions = ["a1b2c3", "d4e5f6"];
const browser = await puppeteer.launch(); // the browser itself starts without a proxy
for (const id of sessions) {
const context = await browser.createBrowserContext({ proxyServer: PROXY });
const page = await context.newPage();
await page.authenticate({ username: `user-session-${id}-ttl-600`, password: "pass" });
for (let i = 0; i < 2; i++) {
await page.goto("https://httpbin.org/ip");
console.log(id, await page.$eval("body", (el) => el.innerText));
}
await context.close(); // cookies, cache and the proxy setting go with the context
}
await browser.close();Our test proxy hands out exits by session ID the way a gateway does. The two contexts left from two different addresses, and each kept its address on the second request. Two more results matter for real jobs:
- Credentials belong to the context, not to the page. A second page in the same context called
page.authenticate()with a different username, yet every request it made still carried the first username. A page that never called it went through on the same cached credentials. - A context without
proxyServerinherits the launch flag's proxy but keeps its own credentials. With--proxy-serveron the browser, two contexts with their own session IDs still left from two different exits.
--proxy-server flag | createBrowserContext({ proxyServer }) | |
|---|---|---|
| Scope | The whole browser | Only that context |
| Separate exits in one browser | Yes, one username per context | Yes, even separate proxy hosts |
| Changing the proxy | Restart the browser | Close the context, open a new one |
| Good for | One script, one exit | Many independent sessions in parallel |
The working rule: one session, one context, one username.
Rotating or sticky: which session type fits a browser?
A browser does not send one request per page. A product page pulls the HTML document, scripts, stylesheets, images and background API calls, often from several hosts over several connections. On a rotating gateway every new connection can get a new exit IP; in our test even a page's favicon left from a different address than the page. For unrelated pages that is harmless. In a login, a cart or a multi-step form, the site sees one visitor jump between addresses and may end the session.
The gateway reads the session type from the username, which the endpoint builder in the dashboard writes for you. Its parts are easy to read: -country-de picks the exit country (the city is chosen in the same builder), -session-a1b2c3-ttl-600 keeps one exit for that session ID for the given number of seconds, anywhere from 1 to 60 minutes, and a username without a session part rotates.
Use Rotating Proxy for independent pages such as category lists or search results, opened in short-lived contexts.
Use Sticky Proxy for anything that holds state: one context, one session ID, kept longer than the flow takes. When the job ends, close the context so the cookies and the IP end together.
Rotation spreads independent work across exits; it is not a way around a site's limits. A 429 Too Many Requests or 403 Forbidden asks you to slow down, and a new IP does not change that answer.
Does Puppeteer work with a SOCKS5 proxy?
Yes, but without a username and password. Chromium's proxy documentation states that no authentication method is supported for SOCKSv5. Our local SOCKS5 server saw it from the other side: Chrome's greeting offered only method 0x00, "no authentication". A server that required the username and password method (0x02) had to reject that offer, and the navigation failed with net::ERR_SOCKS_CONNECTION_FAILED. page.authenticate() made no difference, since SOCKS5 has no 407-style challenge for Puppeteer to answer.
The fix is to prove who you are with your IP address. Add the public IP of the machine that runs Chrome to the IP whitelist in the dashboard (up to 10 addresses), take the SOCKS5 port the endpoint builder shows, and launch without credentials:
import puppeteer from "puppeteer";
// No username or password: this machine's public IP must be on the IP whitelist.
// SOCKS5_PORT: the SOCKS5 port shown in the endpoint builder.
const { SOCKS5_PORT } = process.env;
const browser = await puppeteer.launch({
args: [`--proxy-server=socks5://pr.proxynet.io:${SOCKS5_PORT}`],
});
const page = await browser.newPage();
await page.goto("https://httpbin.org/ip");
console.log(await page.$eval("body", (el) => el.innerText));
await browser.close();Chrome sent host names to the SOCKS5 server, not IP addresses, so DNS is resolved at the proxy and the socks5h:// habit from cURL is not needed. For web pages an HTTP proxy is usually the simpler choice, since it works with page.authenticate().
How do you keep some addresses off the proxy?
The --proxy-bypass-list flag, or proxyBypassList as an array on a context, lists hosts Chrome reaches directly. In the flag, rules are separated by semicolons or commas: --proxy-bypass-list=*.internal.example;192.168.0.0/16. In our test a listed host skipped the proxy entirely and was resolved on the local machine.
Chrome also has implicit rules: localhost, *.localhost, 127.0.0.1/8 and [::1] never use the proxy, even with an empty list. If you test against a local server and the proxy log stays silent, this is why. The special rule <-loopback> removes those implicit rules; with it, our request to 127.0.0.1 went through the proxy.
How do you check the exit IP?
Compare a direct context with a proxied one in the same browser. If the addresses differ, the traffic goes through the proxy:
import puppeteer from "puppeteer";
async function exitIp(context, credentials) {
const page = await context.newPage();
if (credentials) await page.authenticate(credentials);
await page.goto("https://httpbin.org/ip");
const { origin } = JSON.parse(await page.$eval("body", (el) => el.innerText));
await page.close();
return origin;
}
const browser = await puppeteer.launch();
const direct = await browser.createBrowserContext();
const proxied = await browser.createBrowserContext({ proxyServer: "http://pr.proxynet.io:8000" });
const own = await exitIp(direct);
const viaProxy = await exitIp(proxied, { username: "user", password: "pass" });
console.log({ own, viaProxy, proxyWorks: own !== viaProxy });
await browser.close();If you target a country, also look the exit IP up in a geolocation service. When the check fails, take Chrome out of the picture: Is My Proxy Working? How to Test a Proxy has the command-line tests that tell a network problem from a code problem.
Which errors will you see, and what do they mean?
We produced each of these on the local test setup:
| What you see | What happened | What to do |
|---|---|---|
net::ERR_PROXY_CONNECTION_FAILED | Chrome could not reach the proxy: wrong host or port, or outbound traffic is blocked | Check the address and port; retrying will not help |
net::ERR_INVALID_AUTH_CREDENTIALS | The proxy asked for a login and Chrome had none | Call page.authenticate() before the first goto() |
goto() resolves with status 407 | The password was wrong; Puppeteer tried once and gave up | Check response.status(), fix the credentials |
net::ERR_TUNNEL_CONNECTION_FAILED | The login worked, but the proxy could not open the tunnel to the site | Check the target address |
net::ERR_NO_SUPPORTED_PROXIES | Chrome rejected the --proxy-server value | Remove user:pass@, use http:// or socks5:// |
net::ERR_SOCKS_CONNECTION_FAILED | The SOCKS5 server wants a password, or your IP is not whitelisted | Whitelist your IP or use the HTTP port |
| Page loads, but the IP is your own | A bypass rule or a loopback host skipped the proxy | Check the bypass list and <-loopback> |
The wrong-password row hides longest: nothing throws, and the script carries on with the proxy's 407 page. In a desktop browser the tunnel error has more causes, from antivirus HTTPS scanning to a stale system proxy; see How to Fix err_tunnel_connection_failed in Chrome and Edge. While developing, two listeners show the real error behind a timeout:
page.on("requestfailed", (req) => console.log("FAILED", req.url(), req.failure()?.errorText));
page.on("response", (res) => res.status() >= 400 && console.log(res.status(), res.url()));Full example: parallel contexts, sticky sessions and retries
The script opens six JavaScript-rendered pages with at most three contexts at a time. Each attempt gets a fresh context and a fresh sticky session, so cookies and exit IP change together. Images, media and fonts are blocked to save traffic; page.setRequestInterception() does this without breaking page.authenticate(). Timeouts and tunnel failures are retried with exponential backoff, while configuration errors and a 403 or 429 from the site stop the job.
import puppeteer from "puppeteer";
const PROXY = "http://pr.proxynet.io:8000";
const USER = "user";
const PASS = "pass";
const URLS = Array.from({ length: 6 }, (_, i) => `https://quotes.toscrape.com/js/page/${i + 1}/`);
const CONCURRENCY = 3; // contexts open at the same time
const ATTEMPTS = 3;
const BLOCKED = new Set(["image", "media", "font"]);
// Configuration errors: waiting and retrying will not fix them
const FATAL = ["ERR_PROXY_CONNECTION_FAILED", "ERR_NO_SUPPORTED_PROXIES", "ERR_INVALID_AUTH_CREDENTIALS"];
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function scrape(browser, url) {
for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
// One context per attempt: its own cookies and its own sticky session (one exit IP)
const context = await browser.createBrowserContext({ proxyServer: PROXY });
try {
const page = await context.newPage();
const session = crypto.randomUUID().slice(0, 8);
await page.authenticate({ username: `${USER}-session-${session}-ttl-300`, password: PASS });
await page.setRequestInterception(true);
page.on("request", (req) => (BLOCKED.has(req.resourceType()) ? req.abort() : req.continue()));
const res = await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 });
const status = res ? res.status() : 0;
if (status === 403 || status === 429) {
// The site is refusing or slowing you down: a new IP is not the answer
throw Object.assign(new Error(`HTTP ${status}: stop and lower the request rate`), { fatal: true });
}
if (status >= 400) throw new Error(`HTTP ${status}`);
await page.waitForSelector("div.quote span.text", { timeout: 10_000 }); // JavaScript-rendered
return await page.$$eval("div.quote span.text", (els) => els.map((el) => el.textContent));
} catch (err) {
err.fatal ||= FATAL.some((code) => err.message.includes(code));
if (err.fatal || attempt === ATTEMPTS) throw err;
await sleep(2 ** attempt * 1000 + Math.random() * 1000); // back off before the next attempt
} finally {
await context.close();
}
}
}
const browser = await puppeteer.launch();
const queue = [...URLS];
const results = new Map();
await Promise.all(
Array.from({ length: CONCURRENCY }, async () => {
while (queue.length) {
const url = queue.shift();
try {
results.set(url, `${(await scrape(browser, url)).length} quotes`);
} catch (err) {
results.set(url, `ERROR ${err.message.split("\n")[0]}`);
if (err.fatal) queue.length = 0; // stop the whole job, not just this page
}
}
}),
);
await browser.close();
for (const url of URLS) console.log(url, results.get(url) ?? "skipped");Through the local test proxy all six pages came back with ten quotes each in under five seconds. With the proxy port deliberately mistyped, the three pages in progress stopped at once with net::ERR_PROXY_CONNECTION_FAILED and the other three were skipped; a local test page answering 429 ended the job the same way. Start with a low CONCURRENCY: every context holds memory, and the target site has limits too.
Use cases
- JavaScript-rendered catalogue and price pages that a plain HTTP client sees empty.
- Checking how your own site, prices or consent banners look from another country, one context per country.
- Screenshots and PDFs of public pages as a visitor in a given country sees them.
- Monitoring your own pages and checkout flows from outside your network.
The same frame applies to all of them: follow the site's robots.txt and terms, use an official API where one exists, and keep the request rate at a level the site can carry.
Common mistakes
- Writing
user:pass@into--proxy-server. Chrome rejects the value withnet::ERR_NO_SUPPORTED_PROXIES. - Expecting a new username per page. Chrome keeps the first credentials for the whole context; use one context per session.
- Trying a SOCKS5 password. Chrome offers only "no authentication"; use an IP whitelist or the HTTP port.
- Testing against
localhostand trusting the result. Loopback addresses skip the proxy unless you add<-loopback>. - Treating a
407response as a page. A wrong password does not throw; checkresponse.status(). - Rotating the IP after a
429or403. Slow down instead; the limit is about your behaviour, not your address. - Leaving contexts open. Each holds memory; close them in a
finallyblock.
Decision guide
| Need | Recommendation |
|---|---|
| One script, one exit | --proxy-server flag plus page.authenticate() |
| Many independent sessions in one browser | createBrowserContext({ proxyServer }), one session ID per context |
| Bulk pages that do not depend on each other | Rotating username, short-lived contexts |
| Login, cart or multi-step form | Sticky session longer than the flow, one context |
| SOCKS5 is required | IP whitelist, no credentials |
| A tool that accepts only the launch flag | IP whitelist, or a local forwarder such as proxy-chain |
| Internal hosts must stay direct | --proxy-bypass-list or proxyBypassList |
Frequently asked questions
Does puppeteer.launch() have a proxy option?
No. The proxy goes into args as Chrome's --proxy-server flag and the login into page.authenticate(). Puppeteer's own API has proxy fields only on browser.createBrowserContext(): proxyServer and proxyBypassList.
Can every page have its own proxy?
Not within one context. The proxy and the cached credentials belong to the context, so in our test a second page with different credentials still used the first ones. Give each page that needs its own exit its own context; a context is far cheaper than a new browser.
What is proxy-chain, and do you need it?
proxy-chain is an open source Node.js package whose anonymizeProxy() starts a local proxy without a password and forwards traffic to an upstream proxy with credentials, so Chrome gets a plain http://127.0.0.1:<port> address. It worked in our test with both an HTTP and a SOCKS5 upstream that required a password. With page.authenticate() or an IP whitelist you do not need it.
Can you change the proxy without restarting the browser?
Yes, through contexts. Close the context, open a new one with another proxyServer or session ID, and call page.authenticate() again. Only the --proxy-server flag is fixed for the life of the browser.
Why does the proxy log show requests you did not make?
Chrome's own background traffic, such as update checks and Google service requests, goes to the same proxy. In our log most of it arrived without credentials and got a 407, because page.authenticate() answers only challenges from page traffic. It does not affect your pages.
Will a proxy stop CAPTCHAs in Puppeteer?
No. A proxy changes where the traffic comes from, while CAPTCHAs also react to request rate, browser signals and inconsistent sessions. The causes and the legitimate ways to reduce them are in Puppeteer and CAPTCHA: Why It Appears and How to Reduce It.
Summary
In Puppeteer the proxy is a Chrome setting: the --proxy-server flag for the whole browser or proxyServer for one context, with the login in page.authenticate() before the first navigation. Credentials are cached per context, so build each session as a context, pick sticky or rotating through the username, and use an IP whitelist for SOCKS5. Check response.status() and keep the request rate polite. For pages that should open from ordinary home connections, the exits come from a Residential Proxy pool.




