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:
- Selenium Wire (Python): a Selenium extension that routes traffic through its own local proxy and supports authenticated upstreams:
Note that Selenium Wire is no longer actively maintained; it still works with current Chrome for many, but treat it as a stopgap.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) - A generated Chrome extension that sets the proxy via
chrome.proxyand answersonAuthRequiredwith the credentials — a well-known pattern; the extension is a manifest plus a few lines of background script, loaded withopts.add_extension(...). Does not work in headless mode with older Chrome (extensions were unsupported there); works in current headless=new. - A local forwarding proxy on
127.0.0.1that holds the credentials and forwards upstream; point Chrome at it unauthenticated. Framework-agnostic and robust. - 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.webdriveristrueunder 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
| Playwright | Puppeteer | Selenium | |
|---|---|---|---|
| Authenticated proxy | Native (username/password fields) | page.authenticate() per page | Workaround needed (Wire, extension, local forwarder) |
| Different proxy per session | Per context, one browser | One browser per proxy | One driver per proxy |
| SOCKS5 | Yes (no creds → whitelist) | Via flag (no creds) | Via options/prefs |
| Locale / time zone per session | Native context options | Emulation APIs / flags | Prefs / CDP |
| Best for | Multi-identity scraping and testing | Node-centric Chrome automation | Legacy 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.