Browser automation frameworks all take a proxy as a launch option, but authenticated proxies need special handling because Chromium does not accept user:pass@ in a proxy URL: Playwright takes credentials as separate fields (and can set a different proxy per browser context), Puppeteer needs page.authenticate(), and Selenium needs an extension or a local forwarder. This guide gives working code for each, then covers the part that matters more than syntax: a headless browser behind a residential IP is still identifiable unless the rest of its story matches.

The one fact that shapes everything: Chromium ignores proxy credentials in the URL

Command-line tools and Python libraries happily take http://user:pass@host:port. Chromium's --proxy-server flag takes only host:port (with an optional scheme); credentials in it are silently dropped. When the proxy answers 407, the browser shows an authentication dialog — which no automation script is watching. Every framework therefore has its own way to supply the username and password, and that is where most “proxy works in curl but not in my browser script” problems come from. Firefox is more forgiving in some paths, but the framework-specific mechanisms below work across engines.

Playwright

Playwright has first-class proxy support with separate credential fields, and — the feature that makes it the best fit for multi-identity work — the proxy can be set per browser context, so one browser process can host several isolated sessions each exiting from a different IP.

Python

from playwright.sync_api import sync_playwright

PROXY = {"server": "http://proxy.example.com:8080",
         "username": "user", "password": "pass"}

with sync_playwright() as p:
    # option A: one proxy for the whole browser
    browser = p.chromium.launch(proxy=PROXY)
    page = browser.new_page()
    page.goto("https://api.ipify.org?format=json")
    print(page.text_content("body"))
    browser.close()

    # option B: different proxy per context (one browser, many identities)
    browser = p.chromium.launch(proxy={"server": "per-context"})   # placeholder; real proxies come from contexts
    ctx_de = browser.new_context(proxy={"server": "http://gate.provider.com:7777",
                                        "username": "customer-country-de-session-a1", "password": "pass"},
                                 locale="de-DE", timezone_id="Europe/Berlin")
    ctx_us = browser.new_context(proxy={"server": "http://gate.provider.com:7777",
                                        "username": "customer-country-us-session-b2", "password": "pass"},
                                 locale="en-US", timezone_id="America/New_York")
    for ctx in (ctx_de, ctx_us):
        pg = ctx.new_page(); pg.goto("https://api.ipify.org?format=json"); print(pg.text_content("body"))
    browser.close()

Two details. Per-context proxies in Chromium require that the browser was launched with a proxy option (any value — the documented placeholder is {"server": "per-context"}); without it, contexts cannot override. And note locale and timezone_id set alongside the proxy: a German IP with an American time zone is a contradiction anti-bot systems weight, as explained in how anti-bot systems work. SOCKS5 proxies are supported ("server": "socks5://host:1080") but Chromium does not send credentials to SOCKS proxies, so use IP-whitelist authentication with SOCKS.

Node.js

const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch({ proxy: { server: 'per-context' } });
  const context = await browser.newContext({
    proxy: { server: 'http://gate.provider.com:7777',
             username: 'customer-country-de-session-a1', password: 'pass' },
    locale: 'de-DE', timezoneId: 'Europe/Berlin',
  });
  const page = await context.newPage();
  await page.goto('https://api.ipify.org?format=json');
  console.log(await page.textContent('body'));
  await browser.close();
})();

Playwright's bypass field ("bypass": "localhost,*.internal") excludes hosts from the proxy, and launch_persistent_context() takes the same proxy option if you need a persistent profile directory.

Puppeteer

Puppeteer passes the proxy as a Chromium flag and authenticates per page:

const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch({
    args: ['--proxy-server=http://proxy.example.com:8080'],
  });
  const page = await browser.newPage();
  await page.authenticate({ username: 'user', password: 'pass' });   // answers the 407 for this page
  await page.goto('https://api.ipify.org?format=json');
  console.log(await page.evaluate(() => document.body.innerText));
  await browser.close();
})();

page.authenticate() must be called before navigation and on every page (tab) that will use the proxy. The proxy is browser-wide — Puppeteer has no per-context proxy setting the way Playwright does. For several identities you have three options: launch several browser processes (one proxy each; heavy but simple), use --proxy-server pointing at a local switching proxy that routes by some marker, or move to Playwright. Also useful: --proxy-bypass-list=<-loopback> if you need localhost proxied too, and puppeteer-extra with its proxy plugin, which handles authentication and can rotate per page.

Selenium

Selenium sets the proxy through browser options. Unauthenticated (IP-whitelisted) proxies are one line; authenticated ones are the awkward case.

Chrome, whitelist auth

from selenium import webdriver

opts = webdriver.ChromeOptions()
opts.add_argument("--proxy-server=http://proxy.example.com:8080")
driver = webdriver.Chrome(options=opts)
driver.get("https://api.ipify.org?format=json")
print(driver.find_element("tag name", "body").text)
driver.quit()

Chrome, username/password

Selenium's WebDriver protocol has no hook to answer the proxy's 407. The standard workarounds:

  1. Selenium Wire (Python): a Selenium extension that routes traffic through its own local proxy and supports authenticated upstreams:
    from seleniumwire import webdriver   # pip install selenium-wire
    opts = {"proxy": {"http": "http://user:pass@proxy.example.com:8080",
                      "https": "http://user:pass@proxy.example.com:8080"}}
    driver = webdriver.Chrome(seleniumwire_options=opts)
    Note that Selenium Wire is no longer actively maintained; it still works with current Chrome for many, but treat it as a stopgap.
  2. A generated Chrome extension that sets the proxy via chrome.proxy and answers onAuthRequired with the credentials — a well-known pattern; the extension is a manifest plus a few lines of background script, loaded with opts.add_extension(...). Does not work in headless mode with older Chrome (extensions were unsupported there); works in current headless=new.
  3. A local forwarding proxy on 127.0.0.1 that holds the credentials and forwards upstream; point Chrome at it unauthenticated. Framework-agnostic and robust.
  4. Whitelist authentication from the provider, if your machine has a static IP — sidesteps the problem entirely.

Firefox

opts = webdriver.FirefoxOptions()
opts.set_preference("network.proxy.type", 1)
opts.set_preference("network.proxy.http", "proxy.example.com");  opts.set_preference("network.proxy.http_port", 8080)
opts.set_preference("network.proxy.ssl",  "proxy.example.com");  opts.set_preference("network.proxy.ssl_port", 8080)
opts.set_preference("network.proxy.socks_remote_dns", True)     # remote DNS if using SOCKS
driver = webdriver.Firefox(options=opts)

Firefox will still pop an auth dialog for username/password proxies; the local-forwarder or whitelist approaches apply.

What the proxy does not fix in a headless browser

A real browser gets you past TLS and HTTP/2 fingerprinting — the handshake is genuinely Chrome's (see TLS fingerprinting). It does not, by itself, get you past the JavaScript layer:

  • navigator.webdriver is true under automation; sites check it first.
  • Headless mode has rendering and API differences (missing plugins, specific WebGL renderer strings, window dimensions) that fingerprint scripts test for. Chrome's newer headless (headless=new) is much closer to headed but not identical.
  • WebRTC can still expose the machine's real IP unless disabled or spoofed; a proxy does not touch it. Test it — How to test a proxy.
  • Time zone, locale and geolocation come from the machine unless you set them (Playwright: timezone_id, locale, geolocation); a mismatch with the proxy's country is a classic tell.
  • Behaviour: instant navigations, no mouse movement, identical timing every run.

Stealth plugins (puppeteer-extra-plugin-stealth, playwright-stealth) patch the most obvious artefacts; antidetect browsers go further by providing a coherent fingerprint per profile and exposing a debugging port your Playwright/Puppeteer script attaches to — which is the production answer for account work. See what an antidetect browser is and, for choosing the IP type behind each profile, our antidetect proxy ranking.

Choosing the framework for proxy work

PlaywrightPuppeteerSelenium
Authenticated proxyNative (username/password fields)page.authenticate() per pageWorkaround needed (Wire, extension, local forwarder)
Different proxy per sessionPer context, one browserOne browser per proxyOne driver per proxy
SOCKS5Yes (no creds → whitelist)Via flag (no creds)Via options/prefs
Locale / time zone per sessionNative context optionsEmulation APIs / flagsPrefs / CDP
Best forMulti-identity scraping and testingNode-centric Chrome automationLegacy suites, cross-browser grids

Frequently asked questions

How do I use an authenticated proxy in Playwright?

Pass proxy={"server": "http://host:port", "username": "…", "password": "…"} to launch() or, per context, to new_context() (launch with proxy={"server": "per-context"} first).

How do I use a proxy with authentication in Puppeteer?

Launch with args: ['--proxy-server=host:port'] and call await page.authenticate({username, password}) before page.goto() on each page.

Why does Chrome ignore user:pass@ in --proxy-server?

Chromium's flag accepts only scheme://host:port; credentials are not parsed. It responds to the proxy's 407 with an interactive dialog, so automation must supply credentials through the framework's mechanism instead.

Can I use a different proxy for each tab?

Not per tab. Playwright does it per browser context (each context is an isolated session); Puppeteer and Selenium need one browser instance per proxy, or a local switching proxy.

Does a proxy make headless Chrome undetectable?

No. It fixes the IP layer and, being a real browser, the TLS layer. Automation flags, headless artefacts, WebRTC, time-zone mismatch and behaviour are separate signals that need separate handling.

Should the proxy be residential or datacenter for browser automation?

Whatever the target's IP policy requires — the browser does not change that calculus. Datacenter where tolerated; residential or mobile on protected sites; static (ISP) for persistent logged-in profiles. See the proxy types comparison.