Puppeteer Proxy Setup: Authentication, Rotation and SOCKS5

Published:

14 minute read

Acar Diveroli
Written by: Acar Diveroli
The Proxynet wordmark and the white Puppeteer logo side by side in a technical frame, joined by a thin cross

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:

  1. Chrome sends CONNECT httpbin.org:443, asking the proxy to open a tunnel to the site. For an http:// page it sends the request itself.
  2. The request carries no credentials, so the proxy answers 407 Proxy Authentication Required. RFC 9110 defines 407 as the proxy's counterpart of 401.
  3. Puppeteer catches that challenge and answers with the username and password given to page.authenticate().
  4. Chrome repeats the request with a Proxy-Authorization header, the proxy replies 200, and the encrypted connection to the site is built inside the tunnel. The proxy sees the host name, not the page content.
  5. 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:

js
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 first goto(). Called afterwards, the first navigation failed with net::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 with net::ERR_NO_SUPPORTED_PROXIES. Older guides describe a 407 here; today the request never reaches the proxy.
  • Do not send Proxy-Authorization yourself. Setting it with page.setExtraHTTPHeaders() made Chrome refuse the navigation with net::ERR_INVALID_ARGUMENT.
--proxy-server valueResult in Chrome 154
http://pr.proxynet.io:8000Works; HTTPS pages go through a CONNECT tunnel
pr.proxynet.io:8000Works; without a scheme Chrome assumes an HTTP proxy
http://user:pass@pr.proxynet.io:8000net::ERR_NO_SUPPORTED_PROXIES
socks5://host:portWorks only when the server asks for no password
socks5h://host:portnet::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:

js
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 proxyServer inherits the launch flag's proxy but keeps its own credentials. With --proxy-server on the browser, two contexts with their own session IDs still left from two different exits.
--proxy-server flagcreateBrowserContext({ proxyServer })
ScopeThe whole browserOnly that context
Separate exits in one browserYes, one username per contextYes, even separate proxy hosts
Changing the proxyRestart the browserClose the context, open a new one
Good forOne script, one exitMany 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:

js
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:

js
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 seeWhat happenedWhat to do
net::ERR_PROXY_CONNECTION_FAILEDChrome could not reach the proxy: wrong host or port, or outbound traffic is blockedCheck the address and port; retrying will not help
net::ERR_INVALID_AUTH_CREDENTIALSThe proxy asked for a login and Chrome had noneCall page.authenticate() before the first goto()
goto() resolves with status 407The password was wrong; Puppeteer tried once and gave upCheck response.status(), fix the credentials
net::ERR_TUNNEL_CONNECTION_FAILEDThe login worked, but the proxy could not open the tunnel to the siteCheck the target address
net::ERR_NO_SUPPORTED_PROXIESChrome rejected the --proxy-server valueRemove user:pass@, use http:// or socks5://
net::ERR_SOCKS_CONNECTION_FAILEDThe SOCKS5 server wants a password, or your IP is not whitelistedWhitelist your IP or use the HTTP port
Page loads, but the IP is your ownA bypass rule or a loopback host skipped the proxyCheck 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:

js
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.

js
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 with net::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 localhost and trusting the result. Loopback addresses skip the proxy unless you add <-loopback>.
  • Treating a 407 response as a page. A wrong password does not throw; check response.status().
  • Rotating the IP after a 429 or 403. Slow down instead; the limit is about your behaviour, not your address.
  • Leaving contexts open. Each holds memory; close them in a finally block.

Decision guide

NeedRecommendation
One script, one exit--proxy-server flag plus page.authenticate()
Many independent sessions in one browsercreateBrowserContext({ proxyServer }), one session ID per context
Bulk pages that do not depend on each otherRotating username, short-lived contexts
Login, cart or multi-step formSticky session longer than the flow, one context
SOCKS5 is requiredIP whitelist, no credentials
A tool that accepts only the launch flagIP 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.

Ask ChatGPTAsk Claude