Proxy Authentication in Puppeteer and Playwright

Chromium refuses credentials on the command line. Answer the auth challenge, run per-context proxies, and close the DNS and WebRTC side channels.

Chromium accepts a proxy server on the command line but not credentials with it: there is no flag for them, and credentials embedded in --proxy-server are ignored. The browser connects to the proxy, receives the authentication challenge, and expects your script to answer it. In Puppeteer that answer is page.authenticate; in Playwright it is part of the proxy option. A script that never answers does not crash; it receives a 407 page for every navigation.

If a navigation error brought you here, first confirm the endpoint and credentials work in curl (the 407 guide shows how), so the rest of this page is only about the browser layer. Plain HTTP clients in Node are covered in Node.js proxy setup; Selenium has the same credential problem and different answers, in Selenium proxy setup.

Puppeteer

The proxy address goes on Chromium's command line at launch; the credentials go through page.authenticate before the first navigation:

import puppeteer from "puppeteer";

const browser = await puppeteer.launch({
  args: ["--proxy-server=http://HOST:PORT"],
});

const page = await browser.newPage();
await page.authenticate({ username: "USERNAME", password: "PASSWORD" });

await page.goto("https://api.ipify.org/", { waitUntil: "domcontentloaded" });
console.log(await page.$eval("body", (el) => el.innerText.trim()));

await browser.close();

page.authenticate registers a handler for the DevTools-protocol event Chromium emits when a proxy demands authentication and answers it with your credentials. The answer covers everything the page loads (document, images, scripts, XHR and fetch), so one call per page is enough. The rules:

The alternative is IP whitelisting, available on every product: register your server's public address and launch with --proxy-server alone, with no authenticate call. Your egress address must be stable, and on the residential gateway a whitelisted connection sends no username and so cannot carry country, city or session targeting; if you need targeting there, keep the credentials. Details are in IP whitelisting best practices.

Playwright

Playwright takes the address and the credentials in one object:

import { chromium } from "playwright";

const browser = await chromium.launch({
  proxy: {
    server: "http://HOST:PORT",
    username: "USERNAME",
    password: "PASSWORD",
  },
});

const page = await browser.newPage();
await page.goto("https://api.ipify.org/");
console.log((await page.textContent("body")).trim());

await browser.close();

Playwright answers Chromium's challenge internally, and the same option shape works for its Firefox and WebKit engines. There is no per-page call to forget.

Per-context proxies for parallel workers

A Playwright BrowserContext is an isolated profile inside a running browser (its own cookies, storage and cache), and browser.newContext() accepts its own proxy object that overrides the launch setting:

import { chromium } from "playwright";

// The placeholder proxy at launch is deliberate. Some Chromium platform builds
// only honor context-level proxies when the browser was launched with a proxy
// of some kind; launching bare on those builds makes both contexts below
// silently egress from your own address. See the caveat after this example.
const browser = await chromium.launch({ proxy: { server: "http://per-context" } });

const usWorker = await browser.newContext({
  proxy: {
    server: "http://gw-residential.node4.io:8082",
    username: "USERNAME-country-us-session-w1",
    password: "PASSWORD",
  },
});

const deWorker = await browser.newContext({
  proxy: {
    server: "http://gw-residential.node4.io:8082",
    username: "USERNAME-country-de-session-w2",
    password: "PASSWORD",
  },
});

The username suffixes select the exit country and pin a sticky session per worker on the residential gateway, so each context keeps a consistent identity (cookies, storage and address) for its lifetime. A residential session is best effort: the pin lasts while the device behind it stays online, and the address can change if it goes offline, so build the worker to notice and start a fresh context. The full grammar is in residential targeting, the product is on the residential proxies page, and proxies for browser automation compares it with the rotating plans for this workload.

Contexts beat separate browsers because a Chromium instance is a process tree measured in hundreds of megabytes. Ten contexts share one process while keeping cookies, storage and egress separate, and a context is cheap to destroy and recreate: when a worker's session goes bad, tear down the context, make a new one with a fresh session segment, and carry on.

Two caveats. Some Chromium platform builds only honor context-level proxies when the browser was launched with a proxy of some kind; the placeholder server at launch in the example is the documented convention, and "my context proxy is being ignored" is the symptom that your build needs it. And Puppeteer has an equivalent, browser.createBrowserContext({ proxyServer: ... }), but credentials still arrive through page.authenticate on each page inside that context.

DNS and WebRTC

An HTTP client sends only what you told it to. A browser resolves names, opens UDP sockets for WebRTC and prefetches, and only some of that follows your proxy configuration.

DNS. With an HTTP proxy, proxied navigations are safe: the browser hands the hostname to the proxy inside the CONNECT request and resolution happens at our edge. Anything excluded by a --proxy-bypass-list resolves and connects locally, so keep any bypass list to localhost. SOCKS5 is the exception: Chromium resolves hostnames locally before consulting a SOCKS proxy unless you force remote resolution with a host-resolver flag, so if you use our SOCKS5 ports with a browser, treat DNS handling as a configuration item in its own right.

WebRTC. WebRTC discovers connection routes by sending UDP to STUN servers, and that traffic does not go through an HTTP proxy, so a page's JavaScript can learn addresses the proxy was supposed to hide. The mitigation is a launch flag:

const browser = await puppeteer.launch({
  args: [
    "--proxy-server=http://HOST:PORT",
    "--force-webrtc-ip-handling-policy=disable_non_proxied_udp",
  ],
});

The policy stops WebRTC from using routes that bypass the proxy. Pages that need WebRTC (video calls, some conferencing widgets) degrade or fail with it set; for scraping and testing that is usually an acceptable trade.

Closing both channels does not make the browser anonymous. Its timezone, language and rendering fingerprint remain its own, and a target can notice a browser claiming one locale arriving from an exit that implies another. Playwright lets you set locale and timezoneId on each context to match the country its proxy exits from.

Bandwidth in a browser

An HTTP client downloads the document you asked for. A browser downloads the document and everything it references: stylesheets, script bundles, images, fonts, tracking pixels. Through a metered proxy that multiplier is on your bill, and the interception cache-disable above adds to it. Both frameworks can drop resource classes you do not need. Puppeteer:

await page.setRequestInterception(true);
page.on("request", (req) => {
  if (["image", "media", "font"].includes(req.resourceType())) req.abort();
  else req.continue();
});

Playwright scopes the same idea to a context:

await context.route("**/*", (route) => {
  const type = route.request().resourceType();
  if (["image", "media", "font"].includes(type)) return route.abort();
  return route.continue();
});

Test the result against your target before trusting it: some sites change behavior when images fail to load, and a screenshot pipeline cannot abort the pixels it exists to capture. For data extraction the savings are large, and on metered plans (see pricing) this one change often matters more than any HTTP-side tuning.

Verify from inside the browser

Your Node process and your browser can disagree about proxy configuration, so an egress test made with an HTTP client proves nothing about what the pages see:

const page = await usWorker.newPage();
await page.goto("https://api.ipify.org/");
const exitIp = (await page.textContent("body")).trim();
console.log(`context exits via ${exitIp}`);

Run it per context when contexts carry different proxies; asserting that the US worker and the DE worker report different addresses is a one-line test that also catches the placeholder-launch caveat. After setting the WebRTC policy, load one of the public WebRTC-leak test pages in the proxied browser: the only address on display should be the proxy exit. Those two checks are cheap enough to leave in CI.