Playwright makes proxies easy right up to the moment you paste in a username and password, at which point it quietly ignores them. Routing a Playwright browser through a proxy is a two line change. What trips people up is where the proxy goes (at launch, or per browser context) and why credentials embedded in the proxy URL vanish with no error at all. This guide covers proxies in Playwright end to end: per-context configuration across Chromium, Firefox and WebKit, the credentials trap and its one field fix, rotation with sticky sessions, and confirming the exit IP a target actually sees.
We run a proxy network and a live proxy checker, so we watch people hit the same two walls: the silent 407 from credentials in the wrong place, and a rotation loop that leaks the last session onto the next IP because the context was never closed. Every example below is runnable. Where one needs a live proxy, pull a fresh one from our free proxy API, which returns real, recently checked endpoints with no key.
How do you set a proxy in Playwright?
Pass a proxy object with a server field, either globally to chromium.launch() or, better, per context to browser.newContext(). Add username and password as their own fields if the proxy needs authentication, and never inside the server URL. The proxy then covers every page in that browser or context.
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({
proxy: { server: 'http://proxy.example.com:8080', username: 'user', password: 'pass' },
});
const page = await browser.newPage();
await page.goto('https://httpbin.org/ip'); // shows the exit IP the site sees
console.log(await page.textContent('body'));
await browser.close();
})();
Set the proxy at launch, or per context
A proxy set on launch() applies to the whole browser: every context, every page. That is the simplest setup and the right one when the whole job runs through a single exit.
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(
proxy={"server": "http://proxy.example.com:8080", "username": "user", "password": "pass"}
)
page = browser.new_page()
page.goto("https://httpbin.org/ip")
print(page.text_content("body"))
browser.close()
The more useful form sets the proxy per browser context. A context is an isolated session: its own cookies, its own local storage, its own cache. Give each context its own proxy and you can run two identities, on two exit IPs, inside one browser process at the same time.
const browser = await chromium.launch();
const ctxUS = await browser.newContext({
proxy: { server: 'http://us.proxy.example.com:8080', username: 'user', password: 'pass' },
});
const ctxDE = await browser.newContext({
proxy: { server: 'http://de.proxy.example.com:8080', username: 'user', password: 'pass' },
});
await (await ctxUS.newPage()).goto('https://httpbin.org/ip'); // one exit
await (await ctxDE.newPage()).goto('https://httpbin.org/ip'); // a different exit, same browser
Per-context proxying is the foundation of every rotation and sticky-session pattern below, so reach for newContext by default and keep launch-level proxies for the single-exit case.
The credentials trap: why user:pass in the URL disappears
Here is the wall almost everyone hits. Most paid proxies want a username and password, and the obvious place to put them is the URL, the way curl and Python requests accept http://user:pass@host:port. In Playwright that silently fails. Under the hood Playwright launches Chromium with a --proxy-server flag, and that flag carries no credentials, so the user:pass you wrote into server is thrown away. The proxy then answers 407 Proxy Authentication Required and your navigation dies with no obvious cause.
// WRONG: Chromium's proxy flag drops the credentials from the URL, so the proxy returns 407
const context = await browser.newContext({
proxy: { server: 'http://user:[email protected]:8080' },
});
// RIGHT: credentials live in their own fields, and Playwright answers the proxy's auth challenge
const context = await browser.newContext({
proxy: { server: 'http://proxy.example.com:8080', username: 'user', password: 'pass' },
});
If you have worked through our Selenium proxy guide, this is the same gotcha with a much better ending. Selenium's Chrome flag drops credentials too, and the fix there is a third-party tool, selenium-wire, that runs a local proxy to inject the auth. Playwright folds that job into the API: the username and password fields make it answer the proxy's authentication challenge for you, no extra dependency, no local shim in the request path.
The same rule reaches beyond Chromium. It is a Playwright convention, not a browser quirk, so credentials belong in the fields for Firefox and WebKit too.
Chromium, Firefox and WebKit
The proxy object is identical across all three engines Playwright drives. Swap chromium for firefox or webkit and the same { server, username, password } shape works, including per context.
from playwright.sync_api import sync_playwright
proxy = {"server": "http://proxy.example.com:8080", "username": "user", "password": "pass"}
with sync_playwright() as p:
for engine in (p.chromium, p.firefox, p.webkit):
browser = engine.launch(proxy=proxy)
page = browser.new_page()
page.goto("https://httpbin.org/ip")
print(engine.name, page.text_content("body"))
browser.close()
Two engine-level details are worth keeping in mind.
| Capability | Chromium | Firefox | WebKit |
|---|---|---|---|
| HTTP or HTTPS proxy | Yes | Yes | Yes |
Credentials via username / password fields | Yes | Yes | Yes |
SOCKS5 without auth (socks5://host:1080) | Yes | Yes | Yes |
| SOCKS5 with username and password | No | No | No |
SOCKS5 authentication is not supported. Playwright's credential fields are documented for HTTP and HTTPS proxies. A socks5:// server works, but only unauthenticated, so if your SOCKS5 exit needs a login you have to use its HTTP endpoint instead or authenticate by allowlisting your machine's IP with the provider. The bypass field, a comma-separated list like "localhost, .internal.example.com", lets specific hosts skip the proxy entirely.
Older Playwright and Chromium per-context proxies. Current Playwright applies a per-context proxy on Chromium directly. Older releases required launching the browser with a placeholder proxy (server: 'per-context') before context-level proxies would take effect. If a per-context proxy seems ignored on Chromium, upgrade Playwright before you debug anything else, because that one requirement caused a lot of confusion for no reason.
Rotate proxies across contexts
Because each context carries its own exit and its own cookies, rotation is just a loop that opens a fresh context per proxy and closes it when the identity's work is done.
const { chromium } = require('playwright');
const POOL = [
{ server: 'http://p1.example.com:8080', username: 'user', password: 'pass' },
{ server: 'http://p2.example.com:8080', username: 'user', password: 'pass' },
{ server: 'http://p3.example.com:8080', username: 'user', password: 'pass' },
];
(async () => {
const browser = await chromium.launch();
for (const proxy of POOL) {
const context = await browser.newContext({ proxy }); // fresh identity + exit
const page = await context.newPage();
try {
const res = await page.goto('https://httpbin.org/ip', { timeout: 20000 });
console.log(proxy.server, '->', (await res.json()).origin);
// ... your work for this identity ...
} catch (err) {
console.log(proxy.server, 'failed:', err.message); // dead or blocked exit, move on
} finally {
await context.close(); // drops cookies + storage so the next IP starts clean
}
}
await browser.close();
})();
The load-bearing line is context.close() in the finally. Skip it and the cookies from one exit linger in the browser, so the next IP shows up already carrying the last session's state, which is exactly the pairing that gets a rotation run flagged. Closing the context is what makes each new IP a genuinely fresh identity.
For most jobs you do not want to manage a list at all. Point every context at one rotating residential gateway, a single host and port, and let the pool hand you a different exit on each connection. Your code stays a plain one-proxy script and the rotation happens upstream.
gateway = {"server": "http://gateway.example.com:8080", "username": "user", "password": "pass"}
context = browser.new_context(proxy=gateway) # a fresh exit per connection, no list to maintain
Sticky sessions versus per-request rotation
Rotating hard on every request is the wrong instinct against a defended target, and it is worth understanding why. Once you clear a login or a bot challenge, the site hands you a cookie tied to the IP that earned it. Change IP on the next request and that cookie no longer matches its origin, so you get re-challenged as a stranger. A single context on a single exit is already a sticky session: hold it for the whole of one flow.
// One context = one exit, held for the length of a browsing flow
const context = await browser.newContext({
proxy: { server: 'http://gateway.example.com:8080', username: 'user', password: 'pass' },
});
const page = await context.newPage();
await page.goto('https://example.com/login'); // sets a cookie tied to this exit
await page.goto('https://example.com/dashboard'); // same context, same IP, cookie still valid
The rule is simple: rotate between flows, not inside them. New identity means a new context and a new IP; one identity means one context held from start to finish. We go deep on why this beats machine-gun rotation, with the same reasoning applied to a real bot wall, in how to get around DataDome.
Verify the exit IP
Never assume a proxy is working. Confirm the target sees the exit's address and not yours, per context, before you trust a run.
async function exitIp(context) {
const page = await context.newPage();
const res = await page.goto('https://httpbin.org/ip');
const { origin } = await res.json();
await page.close();
return origin;
}
const direct = await exitIp(await browser.newContext());
const viaProxy = await exitIp(await browser.newContext({
proxy: { server: 'http://proxy.example.com:8080', username: 'user', password: 'pass' },
}));
console.log('direct:', direct, 'proxy:', viaProxy);
if (direct === viaProxy) throw new Error('proxy is not changing your IP');
If the two addresses match, the proxy is not routing your traffic, which usually means the credentials landed in the server URL instead of their own fields and the context fell back to a direct connection. It is also worth a look at the exit's real geography and anonymity grade, not just that the IP changed. Our proxy checker runs the whole battery (exit IP, anonymity, real geolocation, latency) in one paste, which is faster than scripting each check by hand.
A proxy fixes the IP, not the fingerprint
This is the honest ceiling, and it is where Playwright scrapers waste the most time. A proxy changes the address a site sees. It does nothing about the other signals. Playwright wins the hardest layer for free, because its TLS and HTTP/2 handshake genuinely is a real browser's: it genuinely is Chromium, Firefox or WebKit. But a default automated session still sets navigator.webdriver to true, exposes automation artifacts, and moves with a precision no hand produces. A serious anti-bot system reads all of that no matter how clean your exit IP is.
We wrote the full breakdown from the defender's seat in how websites detect proxies: the network the IP belongs to, the TLS and HTTP/2 fingerprint, header order, request pacing, and DNS or WebRTC leaks. Hardened Playwright variants and human-like pacing handle the parts a proxy cannot, and the two big vendor walls have their own playbooks in scraping past Cloudflare and getting around DataDome. Set expectations accordingly: a real browser on a good residential IP gets you a long way, and it is not a magic cloak.
Where to point Playwright
For anything facing real defenses the exit IP decides the most, so start there. Our residential proxies route through real consumer ISP connections and rotate from a single gateway, which is the exact shape Playwright wants: one host and port in your proxy object, a fresh believable IP per context or per connection, and sticky sessions when you need to hold one identity across a flow. Pricing is $0.65/GB, pay as you go, and your balance never expires, so a scraping job that idles for a week costs nothing while it waits.
Before you scale, prove one identity end to end: verify the exit with the proxy checker, confirm the fingerprint and pacing on a single page, and only then fan out. If you are driving Playwright from inside a larger crawl, the framework-scale sibling to this guide is how to use proxies with Scrapy, which covers rotation, retries and proxy auth at the pipeline level, including a note on running Playwright as a Scrapy download handler.
Sources
- Playwright: Network, HTTP Proxy: the
proxyobject withserver,username,passwordandbypass, set globally or per context. - Playwright: Browser.newContext, proxy option: the per-context
proxyfields and the note thatserveraccepts HTTP and SOCKS. - MDN: Navigator.webdriver: the automation flag a default Playwright session still exposes to every page.