Guide
Playwright Proxy Setup: Launch, Contexts, Auth and Rotation
How to configure a proxy in Playwright for Python and Node: browser-level and per-context proxies, authentication, bypass lists, rotating IPs per context, and verifying the exit.

In Playwright the proxy goes on the browser, not in the environment. That single fact is the source of most Playwright proxy problems: people set HTTPS_PROXY, the script runs without error, and every request still exits from the host's own IP — because the browser process manages its own networking and does not consult the environment.
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(
proxy={
"server": "http://gateway.example.com:8000",
"username": "your-username",
"password": "your-password",
}
)
page = browser.new_page()
page.goto("https://api.ipify.org")
print(page.inner_text("body")) # the proxy's IP, not yours
browser.close()Note the shape: username and password are separate fields. Embedding them in the server URL as http://user:pass@host:port is the other common failure — it sometimes appears to work and sometimes silently does not authenticate.

Node syntax
Identical structure, camelCase-free:
import { chromium } from "playwright";
const browser = await chromium.launch({
proxy: {
server: "http://gateway.example.com:8000",
username: process.env.PROXY_USER,
password: process.env.PROXY_PASS,
},
});
const page = await browser.newPage();
await page.goto("https://api.ipify.org");
console.log(await page.innerText("body"));
await browser.close();Per-context proxies
The more useful pattern. A browser context is an isolated session — its own cookies, storage and cache — and each one can have its own proxy. That means one browser instance can run several exits concurrently.
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
# Pass a proxy at launch as well; for Chromium this avoids
# version-dependent behaviour with per-context proxies.
browser = p.chromium.launch(proxy={"server": "http://per-context-placeholder:8000"})
ctx_de = browser.new_context(
proxy={
"server": "http://gateway.example.com:8000",
"username": "user-country-de",
"password": "pass",
},
locale="de-DE",
timezone_id="Europe/Berlin",
)
ctx_us = browser.new_context(
proxy={
"server": "http://gateway.example.com:8000",
"username": "user-country-us",
"password": "pass",
},
locale="en-US",
timezone_id="America/New_York",
)
print(ctx_de.new_page().goto("https://api.ipify.org").text())
print(ctx_us.new_page().goto("https://api.ipify.org").text())
browser.close()Two things in that example matter beyond the proxy.
locale and timezone_id are set to match the exit country. This is the highest-leverage thing you can do after getting the proxy working at all. A German exit IP reporting a US English locale and a New York timezone is a contradiction, and detection systems evaluate exactly that kind of internal consistency. Setting them costs nothing. Anti-bot detection in 2026 covers why this now matters more than IP quality alone.
Contexts are cheap; browsers are not. Launching a browser costs hundreds of milliseconds and real memory. Creating a context costs very little. Rotate contexts, reuse browsers.
Bypassing the proxy for some hosts
browser = p.chromium.launch(
proxy={
"server": "http://gateway.example.com:8000",
"username": "u",
"password": "p",
"bypass": "localhost,127.0.0.1,*.internal.example.com",
}
)Worth setting if your automation also talks to local services — without it, those calls try to route outward through the proxy, which either fails or leaks internal hostnames.
Rotation
Gateway rotation needs no code. Point at a rotating endpoint and the provider assigns a new exit per connection:
PROXY = {
"server": "http://gateway.example.com:8000",
"username": os.environ["PROXY_USER"],
"password": os.environ["PROXY_PASS"],
}
for url in urls:
ctx = browser.new_context(proxy=PROXY)
page = ctx.new_page()
page.goto(url)
# ... work ...
ctx.close() # new context, new connection, new exit IPClosing and recreating the context is what triggers a fresh exit, because rotation is typically per connection rather than per request.
Sticky sessions when a flow must hold one identity — login, cart, anything multi-step. Most providers encode the session in the username:
import uuid
session_id = uuid.uuid4().hex[:8]
ctx = browser.new_context(proxy={
"server": "http://gateway.example.com:8000",
"username": f"user-session-{session_id}",
"password": os.environ["PROXY_PASS"],
})
# every request in this context exits from one IPRotating mid-flow is itself a detection signal — it looks like session hijacking. See how to maintain sticky sessions.
Verifying the exit
page.goto("https://api.ipify.org")
proxied_ip = page.inner_text("body").strip()
print(f"exit IP: {proxied_ip}")Do this in the environment where the code actually runs, not just locally. A script tested on a laptop passes whether or not the proxy works, because the laptop's IP is already residential. Deployed to a cloud host, the same script suddenly faces a datacenter IP and starts getting challenged — with no code change. That laptop-to-production gap is the most common surprise in browser automation.
Headless is a separate problem
Worth stating plainly, because it gets blamed on proxies: headless Chromium is detectable in ways headed Chromium is not, and no proxy changes that.
If a target challenges you in headless mode but lets you through headed, the failing signal is the browser, not the IP. Options are to run headed, use a stealth-hardened headless configuration, or accept the challenge rate. Buying better IPs will not move it.
Which proxy type
| Target | Proxy type | Why |
|---|---|---|
| Your own staging site | None | You control it |
| Public docs, open data | Datacenter | Cheap, fast, unfiltered |
| E-commerce, protected sites | Residential | Datacenter ranges filtered first |
| SERP collection | Residential, rotating | Rate limited per address |
| Region-specific verification | Residential with country targeting | Needs a real exit there |
| Logged-in accounts | Static residential | Address must not change |
Playwright launches a real browser, which solves the TLS and browser fingerprint layers that defeat raw HTTP libraries. That makes residential IPs more effective here than they are behind requests, because the other signals are no longer contradicting them.
FlameProxies supports HTTP(S) and SOCKS5 across 80M-plus residential IPs in 180-plus countries at $0.50/GB, falling to $0.45/GB above 1TB, with unlimited concurrent sessions — which matters when you are running many contexts in parallel. Current rates are on the pricing page.
Common errors
Script runs, IP unchanged. The proxy was not passed to the browser. Environment variables do not work here.
ERR_PROXY_CONNECTION_FAILED. Wrong host or port, proxy inactive, or a firewall blocking the port.
Authentication failing silently. Credentials embedded in the server URL. Use the separate username and password fields.
net::ERR_TUNNEL_CONNECTION_FAILED. The proxy refused the CONNECT for that target — often the proxy blocking the destination, or a plan that does not cover it.
Per-context proxy ignored on Chromium. Pass a proxy at launch as well.
Pages load slowly. Real browsers fetch every asset through the proxy — images, fonts, analytics. Block what you do not need:
ctx.route("**/*.{png,jpg,jpeg,webp,svg,woff,woff2,css}", lambda route: route.abort())That one change often cuts bandwidth by most of the page weight, which matters directly on per-gigabyte billing.
Blocked despite residential IPs. Check locale and timezone against the exit country, and check whether you are headless. Those are usually the failing signals, not the address.
For the Puppeteer equivalent — which handles proxy authentication differently and needs a separate call — see Puppeteer proxy setup.
Frequently asked questions
- How do I set a proxy in Playwright?
- Pass a proxy object to browser launch with server, username and password as separate fields: chromium.launch(proxy={'server': 'http://host:port', 'username': 'u', 'password': 'p'}). Playwright takes credentials as their own fields rather than embedded in the server URL, and embedding them there is a common cause of silent authentication failure.
- Can I use a different proxy per browser context in Playwright?
- Yes. Pass a proxy object to new_context() rather than to launch(), which lets one browser instance run several contexts through different exits. For Chromium it is safest to also pass a proxy at launch — historically a launch-level proxy was required for per-context proxies to take effect, and passing one avoids version-dependent behaviour.
- Does the HTTPS_PROXY environment variable work with Playwright?
- Not reliably, because the browser process manages its own networking rather than deferring to the environment. This is the single most common Playwright proxy mistake: the variable is set, the script runs without error, and every request still leaves from the host's own IP. Always pass the proxy explicitly.
- How do I rotate proxies in Playwright?
- Either point at a rotating gateway endpoint and let the provider assign a new exit per connection, or create a fresh browser context per proxy and close it when done. Contexts are cheap compared with launching browsers, so per-context rotation is the practical approach when you need explicit control.
- Why is my Playwright script still blocked with a good proxy?
- Because the IP is only one signal. A real browser engine fixes the TLS and browser fingerprint layers, but the exit country still has to agree with the browser's locale and timezone, and the interaction timing still has to look human. A German exit paired with a US English locale and a New York timezone is a set of contradictions no IP quality can fix.
- Should I use headless or headed Playwright with proxies?
- Headless Chromium is detectable in ways headed Chromium is not, independently of the proxy. If a target challenges you in headless mode but not headed mode, the proxy is not the problem. Run headed, or use a headless configuration explicitly hardened against detection, and treat the proxy as the IP layer only.