---
title: "Puppeteer Proxy Setup: Authentication, Rotation and SOCKS5"
description: "Puppeteer takes a proxy through Chrome's --proxy-server flag or per browser context, with page.authenticate() for the login. Tested examples and errors."
url: https://proxynet.io/blog/puppeteer-proxy
date: 2026-10-06
author: "Acar Diveroli"
category: "Integration, Web Scraping"
lang: en
---

# Puppeteer Proxy Setup: Authentication, Rotation and SOCKS5

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.

> **Note: Short answer**
>
> Puppeteer hands the proxy to Chrome: put `--proxy-server=http://host:port` in the `args` of `puppeteer.launch()`, or pass `proxyServer` to `browser.createBrowserContext()` to give one context its own proxy. The username and password never go into that address; call `page.authenticate({ username, password })` before the first `page.goto()`. Chrome then reuses those credentials for the whole context, so one proxy session means one context. SOCKS5 works only without a password, which is why it is paired with an IP whitelist.

## 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](https://www.rfc-editor.org/rfc/rfc9110#status.407) 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](https://pptr.dev/api/puppeteer.page.authenticate) 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` 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`](https://pptr.dev/api/puppeteer.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` 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](https://proxynet.io/rotating-proxy) for independent pages such as category lists or search results, opened in short-lived contexts.

Use [Sticky Proxy](https://proxynet.io/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](https://chromium.googlesource.com/chromium/src/+/HEAD/net/docs/proxy.md) 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](/blog/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](/blog/err-tunnel-connection-failed). 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

| 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](/blog/puppeteer-captcha).

## 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](https://proxynet.io/residential-proxy) pool.
