Back to blog
ProxiesOct 20, 202613 min read

Selenium Proxy in Python: Setup, Authentication & Rotation (Tested 2026)

We tested 6 ways to use an authenticated proxy with Selenium in 2026. Here's what works on Chrome 155, what breaks, and copy-paste Python code for each.

SimplyNode Team
Engineering & Support · SimplyNode
Robotic arm guiding a browser window through a locked gateway, illustrating proxy authentication in Selenium

Proxy Auth That Actually Works

How to Use a Proxy with Selenium in Python (Tested 2026)

Pointing Selenium at a proxy takes one line. Making it work with a username and password is where most people get stuck. Chrome has no command-line option for proxy credentials. Selenium Wire, the old go-to fix, is no longer maintained. And since Chrome 137, the browser extension trick from older tutorials silently stops working.

So we tested every common method ourselves, on Chrome 155, Chrome for Testing 155/157 and Selenium 4.51 (Python 3.13), against a proxy that requires authentication. This guide shows what works, what doesn’t, and the code for each.

Quick answer

If your proxy doesn’t need a password (for example, your IP is whitelisted with the provider):

from selenium import webdriver
from selenium.webdriver.common.by import By

options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
options.add_argument("--proxy-server=http://PROXY_HOST:PORT")

driver = webdriver.Chrome(options=options)
driver.get("https://httpbin.org/ip")
print(driver.find_element(By.TAG_NAME, "body").text)  # shows the proxy's IP
driver.quit()

If your proxy needs a username and password, Chrome ignores credentials in --proxy-server. Use one of these instead:

  1. Whitelist your IP with the provider and drop the password. Simplest.

  2. Run a tiny local forwarder that adds the password for Chrome. Works everywhere, headless included. Code below.

  3. Answer the auth challenge through CDP. Chrome only, no extra process. Code below.

Test results: which proxy authentication methods work in 2026

How we tested: macOS, Python 3.13, Selenium 4.51 (plus 4.36 on Python 3.9), Google Chrome 155 and Chrome for Testing 155 and 157. Each method ran in headless mode against a local proxy that requires Basic authentication, with a password containing @ and : to catch encoding bugs. A method passed if httpbin.org/ip loaded and the proxy logged an authenticated request. Every Chrome code sample in this guide was run in that setup.

Method

Google Chrome 155

Chrome for Testing

Verdict

IP whitelisting (no password)

✅ Works

not tested

Best if you have a fixed IP

Local forwarder (Python, ~60 lines)

✅ Works

not tested

Best for username:password

CDP Fetch.authRequired

✅ Works

not tested

Good, but Chromium only and async code

Manifest V3 extension + --load-extension

❌ Extension not loaded

✅ Works after ~1s warm-up

Only with Chrome for Testing

BiDi driver.network.add_auth_handler()

❌ Timed out

not tested

Not reliable yet

user:pass@ inside --proxy-server

❌ 407 error

not tested

Never worked in Chrome

Selenium Wire

not tested

not tested

Unmaintained, avoid in new projects

Two findings surprised us:

  • Branded Google Chrome ignores --load-extension since version 137. We confirmed it on Chrome 155: the extension list is empty, and adding --disable-features=DisableLoadExtensionCommandLineSwitch didn’t bring it back. Every tutorial that loads a proxy-auth extension this way is now broken for regular Chrome.

  • The official BiDi auth handler timed out on Chrome 155 with Selenium 4.51 (and crashed with a KeyError inside Selenium on 4.36). It’s the “right” API on paper, so it may work in future releases, but we wouldn’t build on it today.


1. Install Selenium

pip install selenium

Selenium 4.6+ includes Selenium Manager, which downloads the right ChromeDriver for you. You don’t need webdriver-manager anymore.

2. Basic proxy setup (no authentication)

Chrome takes the proxy as a command-line switch:

options = webdriver.ChromeOptions()
options.add_argument("--proxy-server=http://PROXY_HOST:PORT")

A few details that matter:

  • http:// is the protocol used to talk to the proxy. HTTPS sites still work: Chrome opens a CONNECT tunnel and encrypts traffic end to end.

  • For SOCKS5, use --proxy-server=socks5://PROXY_HOST:PORT. Chrome resolves DNS through the SOCKS5 proxy, so there’s no DNS leak.

  • To send some hosts directly, add a bypass list:

options.add_argument("--proxy-bypass-list=localhost;127.0.0.1;*.internal.company.com")

Always verify. Load https://httpbin.org/ip (or https://ipinfo.io/json to also see the country) and check that the IP isn’t yours. It takes two seconds and catches most setup mistakes.

3. Why proxy authentication is hard in Selenium

When a proxy needs a password, it answers Chrome with 407 Proxy Authentication Required. A human would see a login popup. Selenium can’t type into that popup, and in headless mode there isn’t one: the page just fails with ERR_TUNNEL_CONNECTION_FAILED or a blank page.

And this doesn’t work, even though it looks like it should:

# Doesn't work: Chrome ignores credentials in --proxy-server
options.add_argument("--proxy-server=http://USERNAME:PASSWORD@PROXY_HOST:PORT")

Here are the methods that do.

Method 1: IP whitelisting (simplest)

Most residential proxy providers let you authorize your server’s IP instead of using a password. SimplyNode supports both: add your IP in the dashboard, and the proxy accepts connections from it without credentials. Then the quick answer code works as-is.

Good for: servers and VPS with a static IP.
Not good for: laptops, CI runners, cloud functions, or anything whose IP changes.

More on the trade-offs: IP whitelisting vs username:password.

Method 2: A local forwarder (recommended for username:password)

The idea: run a small proxy on your own machine that doesn’t need a password. Chrome connects to it. The forwarder adds the Proxy-Authorization header and passes everything to your real proxy.

Chrome  ->  127.0.0.1:8900 (no password)  ->  your proxy (with password)  ->  website

Because Chrome sees a password-free proxy, this works in headless mode and in regular Chrome, and any other browser (Firefox, Edge) can use it the same way. It uses only the Python standard library. HTTPS traffic stays end-to-end encrypted: the forwarder only sees the CONNECT line, not the page content.

Save this as proxy_forwarder.py:

"""proxy_forwarder.py: a local, password-free proxy that forwards to your authenticated proxy.

Chrome connects to 127.0.0.1:<listen_port> without credentials. The forwarder adds the
Proxy-Authorization header and passes all traffic to the upstream proxy.
"""
import asyncio
import base64
import os
import socket
import threading
import time


async def _pipe(reader, writer):
    try:
        while data := await reader.read(65536):
            writer.write(data)
            await writer.drain()
    except (ConnectionError, asyncio.CancelledError):
        pass
    finally:
        writer.close()


async def serve(listen_port, upstream_host, upstream_port, user, password):
    auth = b"Proxy-Authorization: Basic " + base64.b64encode(f"{user}:{password}".encode()) + b"\r\n"

    async def handle(client_r, client_w):
        try:
            head = await client_r.readuntil(b"\r\n\r\n")
            up_r, up_w = await asyncio.open_connection(upstream_host, upstream_port)
        except (asyncio.IncompleteReadError, OSError):
            client_w.close()
            return
        first_line, rest = head.split(b"\r\n", 1)
        if not first_line.startswith(b"CONNECT"):
            # Plain http:// request: close after it, so the next one
            # opens a new connection and gets the auth header too.
            rest = b"Connection: close\r\n" + rest
        up_w.write(first_line + b"\r\n" + auth + rest)
        await up_w.drain()
        await asyncio.gather(_pipe(client_r, up_w), _pipe(up_r, client_w))

    server = await asyncio.start_server(handle, "127.0.0.1", listen_port)
    async with server:
        await server.serve_forever()


def start_in_background(listen_port, upstream_host, upstream_port, user, password):
    """Run the forwarder in a daemon thread, so it lives as long as your script."""
    thread = threading.Thread(
        target=asyncio.run,
        args=(serve(listen_port, upstream_host, upstream_port, user, password),),
        daemon=True,
    )
    thread.start()
    for _ in range(50):  # wait up to 5s until the port accepts connections
        try:
            socket.create_connection(("127.0.0.1", listen_port), timeout=0.1).close()
            return thread
        except OSError:
            time.sleep(0.1)
    raise RuntimeError(f"Forwarder did not start on port {listen_port}")


if __name__ == "__main__":
    port = int(os.environ.get("LISTEN_PORT", "8900"))
    print(f"Forwarding 127.0.0.1:{port} -> {os.environ['PROXY_HOST']}:{os.environ['PROXY_PORT']}")
    asyncio.run(serve(
        port,
        os.environ["PROXY_HOST"],
        int(os.environ["PROXY_PORT"]),
        os.environ["PROXY_USER"],
        os.environ["PROXY_PASS"],
    ))

Then use it from your Selenium script:

import os

from selenium import webdriver
from selenium.webdriver.common.by import By

from proxy_forwarder import start_in_background

start_in_background(
    8900,
    os.environ["PROXY_HOST"],
    int(os.environ["PROXY_PORT"]),
    os.environ["PROXY_USER"],
    os.environ["PROXY_PASS"],
)

options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
options.add_argument("--proxy-server=http://127.0.0.1:8900")

driver = webdriver.Chrome(options=options)
driver.get("https://httpbin.org/ip")
print(driver.find_element(By.TAG_NAME, "body").text)
driver.quit()

Set the credentials as environment variables, so they never end up in your code or Git history:

export PROXY_HOST="your proxy host from the dashboard"
export PROXY_PORT="your proxy port"
export PROXY_USER="your_username"
export PROXY_PASS="your_password"
python scraper.py

You can also run the forwarder as a separate process (python proxy_forwarder.py) and point any browser or tool at 127.0.0.1:8900.

Limitations: the upstream proxy must be an HTTP proxy (SimplyNode’s HTTP endpoint works, and it also tunnels HTTPS). The forwarder listens only on 127.0.0.1, so other machines on your network can’t use your credentials through it.

Method 3: Chrome DevTools Protocol (CDP)

Chrome’s DevTools Protocol can pause a request when the proxy asks for credentials and answer with your username and password. No extra process, no extension. The catch: it needs async event handling, which Selenium exposes through trio.

pip install selenium trio
import os

import trio
from selenium import webdriver
from selenium.webdriver.common.by import By

PROXY = f'{os.environ["PROXY_HOST"]}:{os.environ["PROXY_PORT"]}'
USER, PASS = os.environ["PROXY_USER"], os.environ["PROXY_PASS"]


async def run(driver, urls):
    async with driver.bidi_connection() as conn:
        session, devtools = conn.session, conn.devtools
        await session.execute(devtools.fetch.enable(handle_auth_requests=True))

        async def continue_paused_requests():
            async for event in session.listen(devtools.fetch.RequestPaused):
                await session.execute(devtools.fetch.continue_request(event.request_id))

        async def answer_auth_challenges():
            async for event in session.listen(devtools.fetch.AuthRequired):
                await session.execute(devtools.fetch.continue_with_auth(
                    event.request_id,
                    devtools.fetch.AuthChallengeResponse("ProvideCredentials", USER, PASS),
                ))

        async with trio.open_nursery() as nursery:
            nursery.start_soon(continue_paused_requests)
            nursery.start_soon(answer_auth_challenges)
            for url in urls:
                # driver.get blocks, so run it in a thread while the listeners keep working
                await trio.to_thread.run_sync(driver.get, url)
                print(driver.find_element(By.TAG_NAME, "body").text[:200])
            nursery.cancel_scope.cancel()


options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
options.add_argument(f"--proxy-server=http://{PROXY}")
driver = webdriver.Chrome(options=options)
try:
    trio.run(run, driver, ["https://httpbin.org/ip"])
finally:
    driver.quit()

Trade-offs: Fetch.enable pauses every request, not only auth challenges, so each request makes a round trip through your Python code. That’s fine for scraping a few thousand pages, but it slows down heavy pages with hundreds of assets. It also only works in Chromium-based browsers, and your scraping logic has to live inside the trio block.

Method 4: A proxy-auth extension (Chrome for Testing only)

The classic approach: a tiny Manifest V3 extension answers the auth challenge. It still works, but not in branded Google Chrome 137+, which ignores --load-extension. You need Chrome for Testing, which Selenium Manager can download for you.

import json
import os
import tempfile
import time

from selenium import webdriver
from selenium.webdriver.common.by import By

PROXY = f'{os.environ["PROXY_HOST"]}:{os.environ["PROXY_PORT"]}'
USER, PASS = os.environ["PROXY_USER"], os.environ["PROXY_PASS"]


def make_proxy_auth_extension(user, password):
    folder = tempfile.mkdtemp()
    manifest = {
        "manifest_version": 3,
        "name": "Proxy auth",
        "version": "1.0",
        "permissions": ["webRequest", "webRequestAuthProvider"],
        "host_permissions": ["<all_urls>"],
        "background": {"service_worker": "background.js"},
    }
    with open(os.path.join(folder, "manifest.json"), "w") as f:
        json.dump(manifest, f)
    with open(os.path.join(folder, "background.js"), "w") as f:
        f.write(
            "chrome.webRequest.onAuthRequired.addListener("
            f"(details, callback) => callback({{authCredentials: {{username: {json.dumps(user)}, password: {json.dumps(password)}}}}}),"
            "{urls: ['<all_urls>']}, ['asyncBlocking']);"
        )
    return folder


options = webdriver.ChromeOptions()
options.browser_version = "stable"  # with SE_FORCE_BROWSER_DOWNLOAD=true -> Chrome for Testing
options.add_argument("--headless=new")
options.add_argument(f"--proxy-server=http://{PROXY}")
options.add_argument(f"--load-extension={make_proxy_auth_extension(USER, PASS)}")

driver = webdriver.Chrome(options=options)
time.sleep(2)  # let the extension's service worker start before the first request
driver.get("https://httpbin.org/ip")
print(driver.find_element(By.TAG_NAME, "body").text)
driver.quit()

Run it with:

SE_FORCE_BROWSER_DOWNLOAD=true python scraper.py

SE_FORCE_BROWSER_DOWNLOAD=true tells Selenium Manager to download Chrome for Testing instead of using your installed Chrome.

The warm-up matters. In our tests, the first page load right after launch failed with a 407 in 3 out of 3 runs, because the extension’s service worker wasn’t listening yet. A 1–2 second pause fixed it in every run. Waiting for the service worker to appear in the target list did not help, because it appears before the listener is registered.

What doesn’t work (and why)

Selenium Wire. For years it was the standard answer: seleniumwire.webdriver.Chrome(seleniumwire_options={"proxy": {...}}). The project is no longer maintained, breaks with newer versions of its dependencies (blinker), and decrypts all HTTPS traffic through its own certificate, which some sites detect. Fine for existing scripts, risky for new ones.

BiDi add_auth_handler. Selenium 4 has a cross-browser API for exactly this:

options.enable_bidi = True
driver = webdriver.Chrome(options=options)
driver.network.add_auth_handler(USER, PASS)

In our tests the page load timed out on Chrome 155 with Selenium 4.51, and on Selenium 4.36 it crashed with a KeyError inside Selenium’s WebSocket code. Keep an eye on it: once it works, it’ll be the cleanest option.

Credentials in --proxy-server. Chrome strips them. You get a 407.

webdriver.Proxy with credentials. The Proxy object has socks_username and socks_password, but Chrome ignores them, and it doesn’t support SOCKS5 authentication at all.

Firefox

Firefox takes proxy settings as preferences:

from selenium import webdriver

options = webdriver.FirefoxOptions()
options.add_argument("-headless")
options.set_preference("network.proxy.type", 1)  # manual proxy configuration
options.set_preference("network.proxy.http", "127.0.0.1")
options.set_preference("network.proxy.http_port", 8900)
options.set_preference("network.proxy.ssl", "127.0.0.1")
options.set_preference("network.proxy.ssl_port", 8900)

driver = webdriver.Firefox(options=options)

For a password-protected proxy, point Firefox at the local forwarder from Method 2, as in the example above. Or set network.proxy.http / network.proxy.ssl directly to your proxy host if your IP is whitelisted.

Rotating proxies in Selenium

A browser isn’t like requests: it keeps connections open, stores cookies and builds a fingerprint over time. Rotating the IP in the middle of a browser session usually looks more suspicious, not less. The practical rule is one browser session = one IP.

There are two good patterns.

Pattern 1: Rotating gateway, new browser per task. In the SimplyNode dashboard, set rotation to New IP on each request or by time. Each new browser opens new connections and gets a different exit IP:

for task in tasks:
    driver = webdriver.Chrome(options=options)  # options point to the forwarder or gateway
    try:
        scrape(driver, task)
    finally:
        driver.quit()

Pattern 2: Sticky endpoints, one per account or worker. For logins and multi-step flows, use sticky sessions (SimplyNode keeps an IP for 90 seconds up to 6 hours, or sticky per port). Export the proxy list, then give each browser its own endpoint. With the forwarder, run one local port per endpoint:

import os

from selenium import webdriver

from proxy_forwarder import start_in_background

# proxies.txt from the dashboard, one "host:port" per line, each port is its own sticky IP
with open("proxies.txt") as f:
    endpoints = [line.strip() for line in f if line.strip()]

user, password = os.environ["PROXY_USER"], os.environ["PROXY_PASS"]


def driver_for(worker_id):
    host, port = endpoints[worker_id % len(endpoints)].rsplit(":", 1)
    local_port = 8900 + worker_id
    start_in_background(local_port, host, int(port), user, password)
    options = webdriver.ChromeOptions()
    options.add_argument("--headless=new")
    options.add_argument(f"--proxy-server=http://127.0.0.1:{local_port}")
    return webdriver.Chrome(options=options)

Read more about choosing between the two: Rotating vs sticky residential sessions.

Save proxy bandwidth: block images and heavy assets

Residential proxies are usually billed per GB, and a browser downloads everything: images, fonts, video, ads, trackers. In our test, the BBC News homepage transferred 3.5 MB through the proxy in a browser. If you only need the data, block what you don’t need:

options = webdriver.ChromeOptions()
options.add_experimental_option("prefs", {
    "profile.managed_default_content_settings.images": 2,  # don't load images
})
driver = webdriver.Chrome(options=options)

# Block other heavy resources by URL pattern
driver.execute_cdp_cmd("Network.enable", {})
driver.execute_cdp_cmd("Network.setBlockedURLs", {"urls": [
    "*.mp4", "*.webm", "*.woff", "*.woff2", "*.ttf",
    "*google-analytics.com*", "*googletagmanager.com*", "*doubleclick.net*",
]})

We measured the effect through the proxy (Chrome network logs, same pages with and without blocking):

Page

Without blocking

With blocking

Saved

BBC News homepage

3.47 MB

2.31 MB

33%

Wikipedia article

0.50 MB

0.39 MB

22%

The more images and video a page has, the more you save. Over a million page loads, a third less traffic is a third off your proxy bill. Don’t block CSS or JavaScript if the site needs them to render the data you want.

Estimate your traffic before you buy: How much proxy bandwidth do you actually need?

Match the browser to the proxy location

A German IP with a browser that says en-US and America/New_York is an easy inconsistency to flag. When you use geo-targeted proxies, align the language and timezone:

options = webdriver.ChromeOptions()
options.add_argument("--lang=de-DE")
options.add_experimental_option("prefs", {"intl.accept_languages": "de-DE,de"})
driver = webdriver.Chrome(options=options)
driver.execute_cdp_cmd("Emulation.setTimezoneOverride", {"timezoneId": "Europe/Berlin"})

Also prevent WebRTC from revealing your real IP over UDP, which bypasses HTTP proxies:

options.add_argument("--force-webrtc-ip-handling-policy=disable_non_proxied_udp")

A proxy changes your IP. It doesn’t hide that the browser is automated: navigator.webdriver, headless traits and the browser fingerprint are separate signals. See Antibot & fingerprinting and Headless browsers and stealth.

Troubleshooting

Error or symptom

Cause

Fix

ERR_TUNNEL_CONNECTION_FAILED, blank page

Proxy returned 407: credentials missing or wrong

Use whitelisting, the forwarder or CDP. Check username and password. See 407 errors.

ERR_PROXY_CONNECTION_FAILED

Chrome can’t reach the proxy: wrong host or port, forwarder not running, firewall

Check host and port in the dashboard. Test with curl -x http://host:port https://httpbin.org/ip.

Login popup appears (non-headless)

Proxy needs auth and nothing answers it

Same as 407: use one of the auth methods above.

Extension method does nothing

Branded Chrome 137+ ignores --load-extension

Use Chrome for Testing (SE_FORCE_BROWSER_DOWNLOAD=true) or switch to the forwarder.

Extension works only on the second page

Service worker not ready on first navigation

time.sleep(2) after starting the driver.

Page shows your real IP

Typo in --proxy-server, or the option added after the driver started

Print options.arguments and verify with httpbin.org/ip.

SOCKS5 proxy with a password fails

Chrome doesn’t support SOCKS5 authentication

Use the provider’s HTTP endpoint with the forwarder, or whitelist your IP.

TimeoutException on page load

Slow exit IP or heavy page

driver.set_page_load_timeout(30), block heavy assets, retry with a new browser.

403 / CAPTCHA with a good residential IP

Fingerprint or behavior, not the IP

Match language and timezone, slow down, use residential or mobile IPs for strict sites.

Which proxies to use with Selenium

Proxy type

Works for

Detection risk

Datacenter

Testing, sites without anti-bot protection

High

Residential

E-commerce, travel, SERPs, most scraping

Low

Mobile (4G/5G)

Social media, strictest anti-bot sites

Lowest

Browsers use more bandwidth than HTTP clients, so pricing per GB matters more for Selenium than for requests. Block heavy assets (see above), and choose a provider whose traffic doesn’t expire if your workload is uneven.

Get residential proxies for Selenium

Every method in this guide works with SimplyNode:

  • 8M+ residential and 1.4M+ mobile IPs in 185+ countries, with city and ISP targeting

  • IP whitelisting or username:password, so you can skip auth workarounds entirely on servers

  • Rotation per request, by time (90s to 6h), or sticky

  • Unlimited concurrent sessions, so you can run as many browsers as your machine handles

  • Pay-as-you-go from $2.25/GB, no subscription, and traffic that doesn’t expire

Get residential proxies →

FAQ

How do I set a proxy in Selenium with Python?

Add --proxy-server=http://HOST:PORT to ChromeOptions with options.add_argument(...), then pass the options to webdriver.Chrome(). For Firefox, set the network.proxy.* preferences in FirefoxOptions.

How do I use a proxy with username and password in Selenium?

Chrome ignores credentials in --proxy-server. In our 2026 tests, three methods worked: IP whitelisting with your provider, a local forwarder that adds the Proxy-Authorization header, and answering Fetch.authRequired through the Chrome DevTools Protocol. A Manifest V3 extension also works, but only in Chrome for Testing.

Does Selenium Wire still work?

It still runs in many existing projects, but it’s no longer maintained and has dependency conflicts with newer packages. For new projects, use IP whitelisting, a local forwarder or CDP.

Why doesn’t my proxy auth extension load in Chrome?

Google Chrome 137 and later ignores the --load-extension command-line switch. Use Chrome for Testing (set SE_FORCE_BROWSER_DOWNLOAD=true with options.browser_version = "stable") or switch to a method that doesn’t need an extension.

Can I use a SOCKS5 proxy with Selenium?

Yes, without authentication: --proxy-server=socks5://HOST:PORT. Chrome doesn’t support SOCKS5 authentication, so for a password-protected proxy use the provider’s HTTP endpoint or IP whitelisting.

How do I rotate proxies in Selenium?

Start a new browser for each IP. Use a rotating gateway and open a fresh driver per task, or give each worker its own sticky endpoint. Changing the IP mid-session usually triggers more blocks, because cookies and the fingerprint stay the same.

Does the proxy work in headless mode?

Yes. --proxy-server works the same with --headless=new. The difference is authentication: there’s no login popup in headless mode, so you need whitelisting, the forwarder, CDP or an extension.

How do I check that Selenium is using the proxy?

Open https://httpbin.org/ip or https://ipinfo.io/json with the driver and compare the IP with your own. ipinfo.io also shows the country and city, which is useful for geo-targeted proxies.

Why does Selenium use so much proxy traffic?

A browser loads images, fonts, video and trackers. Block images with Chrome prefs and other assets with Network.setBlockedURLs. In our tests, that cut traffic by 22–33% on content-heavy pages.


Related reading

SimplyNode Team
Oct 20, 2026
SN
SimplyNode Team
Engineering & Support · SimplyNode

The team behind the SimplyNode network - residential and mobile proxies, 8M+ ethically-sourced IPs, a 99.3% success rate. We write about the practical infrastructure work behind reliable scraping.

All articles by SimplyNode Team