Tutorial · 7 min read

Selenium with an authenticated proxy: what works in 2026

Chrome and Firefox under Selenium will not take a proxy password on the command line. Four ways that work in 2026, with Python code, and the one to skip.

You point Selenium at a proxy that needs a username and password, and Chrome pops up a sign-in box that WebDriver cannot click. Or it shows an error page. Or the script just sits there until the page-load timeout. None of this is your fault; it is how the browsers are built.

Below are four ways to run Selenium through an authenticated proxy that work in 2026, each tested with Selenium 4.49 against Chrome 154 and Firefox 156, plus the popular one to skip.

Why can’t Selenium send a proxy username and password?

Because neither browser takes one at launch. Chrome’s --proxy-server flag has a host and a port and nowhere to put credentials, and Firefox’s proxy preferences have no password field either. Selenium’s proxy options just fill in those same settings. Writing the credentials into the URL looks like it should work, and does not:

options.add_argument("--proxy-server=http://USER:[email protected]:8000")

In our test Chrome loaded an error page instead. When the proxy asks for a password, both browsers ask the human at the keyboard, and in an automated session there is not one. So you either remove the question or answer it some other way.

Four ways to use an authenticated proxy with Selenium
ApproachBrowsersExtra moving parts
IP allowlistAnyNone, if your IP is fixed
Local forwarding proxyAnyOne small process
Generated extensionChromeAn extension folder and BiDi
BiDi auth handlerChrome, FirefoxNone; a Chrome quirk to know

Option 1: allowlist your IP and skip the password

Every line we sell supports an IP allowlist as well as a username and password. Add the public IP of the machine that runs Selenium in the dashboard, and the proxy stops asking. There is nothing left to authenticate, so the plain proxy options work in both browsers:

from selenium import webdriver
from selenium.webdriver.common.proxy import Proxy, ProxyType

PROXY = "resi.proxymonkey.io:8000"

options = webdriver.ChromeOptions()  # or webdriver.FirefoxOptions()
options.proxy = Proxy({"proxyType": ProxyType.MANUAL, "httpProxy": PROXY, "sslProxy": PROXY})

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

This is the best option when it fits, and it fits servers with a fixed IP. It does not fit a laptop, a home connection or a CI runner whose address changes, and anyone else behind the same public IP can use the proxy too.

Option 2: a local forwarding proxy that adds the credentials

If the browser cannot send a password, put something next to it that can. This script listens on your machine, adds a Proxy-Authorization header to each request, and passes everything on to the gateway. The browser sees a proxy with no password; the gateway sees a proxy client with the right one.

# relay.py
import asyncio
import base64

LISTEN = ("127.0.0.1", 8899)
UPSTREAM = ("resi.proxymonkey.io", 8000)
USER, PASS = "USER", "PASS"

AUTH = b"Proxy-Authorization: Basic " + base64.b64encode(f"{USER}:{PASS}".encode()) + b"\r\n"
DROP = (b"proxy-authorization:", b"proxy-connection:", b"connection:")


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


async def handle(client_reader, client_writer):
    try:
        head = await client_reader.readuntil(b"\r\n\r\n")
    except (asyncio.IncompleteReadError, asyncio.LimitOverrunError):
        client_writer.close()
        return
    first, *headers = head[:-4].split(b"\r\n")
    kept = [h for h in headers if not h.lower().startswith(DROP)]
    if not first.startswith(b"CONNECT "):
        kept.append(b"Connection: close")
    head = b"\r\n".join([first, *kept]) + b"\r\n" + AUTH + b"\r\n"

    upstream_reader, upstream_writer = await asyncio.open_connection(*UPSTREAM)
    upstream_writer.write(head)
    await asyncio.gather(
        pipe(client_reader, upstream_writer),
        pipe(upstream_reader, client_writer),
    )


async def main():
    server = await asyncio.start_server(handle, *LISTEN)
    print(f"forwarding {LISTEN[0]}:{LISTEN[1]} -> {UPSTREAM[0]}:{UPSTREAM[1]}")
    async with server:
        await server.serve_forever()


if __name__ == "__main__":
    asyncio.run(main())

Run python relay.py, then point the browser at it with the same code as option 1 and PROXY = "127.0.0.1:8899". HTTPS sites use a CONNECT tunnel, which carries the header once and then just relays encrypted bytes, so the script never sees your traffic in the clear. Plain HTTP requests get Connection: close, so each one arrives on its own connection with its own header.

Rotation still works the way it does without the relay: each new connection to the gateway gets a new exit IP. Keep the listener on 127.0.0.1. Bound to a public interface, it is an open proxy running on your credentials.

Prefer an off-the-shelf tool? pproxy does the same in one line (the credentials go after the #):

pip install pproxy
pproxy -l http://127.0.0.1:8899 -r "http://resi.proxymonkey.io:8000#USER:PASS"

Option 3: a Chrome extension generated on the fly (Manifest V3)

Extensions can still answer proxy logins under Manifest V3. The webRequestBlocking permission is now limited to policy-installed extensions, but Chrome’s documentation keeps a separate webRequestAuthProvider permission for exactly this, and with it a blocking onAuthRequired listener works. This function writes such an extension to a temporary folder:

import json
import tempfile
from pathlib import Path


def proxy_extension(host, port, user, password):
    folder = Path(tempfile.mkdtemp(prefix="proxy-auth-"))
    manifest = {
        "manifest_version": 3,
        "name": "proxy-auth",
        "version": "1.0",
        "permissions": ["proxy", "webRequest", "webRequestAuthProvider"],
        "host_permissions": ["<all_urls>"],
        "background": {"service_worker": "background.js"},
    }
    background = f"""
chrome.proxy.settings.set({{
  value: {{
    mode: "fixed_servers",
    rules: {{ singleProxy: {{ scheme: "http", host: {json.dumps(host)}, port: {int(port)} }} }},
  }},
  scope: "regular",
}});

chrome.webRequest.onAuthRequired.addListener(
  (details) => details.isProxy
    ? {{ authCredentials: {{ username: {json.dumps(user)}, password: {json.dumps(password)} }} }}
    : {{}},
  {{ urls: ["<all_urls>"] }},
  ["blocking"],
);
"""
    (folder / "manifest.json").write_text(json.dumps(manifest, indent=2))
    (folder / "background.js").write_text(background)
    return str(folder)

The isProxy check means it only answers the proxy, never a site’s own login box. Loading it is where the old recipes break: the --load-extension flag they use was silently ignored by branded Chrome 154 in our test, and traffic went direct. Selenium’s BiDi install command works instead:

from selenium import webdriver

options = webdriver.ChromeOptions()
options.enable_webextensions = True
options.enable_bidi = True

driver = webdriver.Chrome(options=options)
driver.webextension.install(path=proxy_extension("resi.proxymonkey.io", 8000, "USER", "PASS"))
driver.get("https://httpbin.org/ip")

enable_webextensions adds two Chrome flags, one of which connects the driver over a pipe instead of a port and turns off some DevTools features. If you rely on those, pick another option.

Option 4: Selenium 4 BiDi and add_auth_handler

WebDriver BiDi, the newer protocol Selenium 4 speaks next to the classic one, can answer authentication challenges, proxy ones included. It is the least code of the four:

from selenium import webdriver

options = webdriver.ChromeOptions()
options.add_argument("--proxy-server=http://resi.proxymonkey.io:8000")
options.enable_bidi = True

driver = webdriver.Chrome(options=options)
driver.set_page_load_timeout(30)
driver.network.add_auth_handler("USER", "PASS")

driver.browsing_context.navigate(
    context=driver.current_window_handle,
    url="https://httpbin.org/ip",
    wait="complete",
)
driver.get("https://httpbin.org/headers")

Note the first page. With ChromeDriver, a plain driver.get that hits the proxy’s challenge hung in our tests until it timed out: the classic command waits for the page, and the answer to the challenge waits behind the classic command. Doing the first navigation over BiDi lets the answer through, and after that Chrome reuses the credentials, so ordinary driver.get calls work. Set a page-load timeout regardless, so a challenge that does slip through fails in 30 seconds instead of hanging a worker.

Firefox had no such quirk. This worked from the first page:

from selenium import webdriver
from selenium.webdriver.common.proxy import Proxy, ProxyType

PROXY = "resi.proxymonkey.io:8000"

options = webdriver.FirefoxOptions()
options.proxy = Proxy({"proxyType": ProxyType.MANUAL, "httpProxy": PROXY, "sslProxy": PROXY})
options.enable_bidi = True

driver = webdriver.Firefox(options=options)
driver.network.add_auth_handler("USER", "PASS")
driver.get("https://httpbin.org/ip")

The Chrome DevTools Protocol’s Fetch.authRequired is the older, Chrome-only way to do this. BiDi is the standard replacement, so there is little reason to hand-roll it in 2026.

How do I check Selenium is really using the proxy?

Load https://httpbin.org/ip and compare the address with your own. Do not skip this: in our tests, a proxy setting the browser quietly ignored did not fail, it just loaded the page directly from the machine’s own IP. A page that loads proves nothing.

Then think about what one page means on a rotating line. A single page load is dozens of requests over several connections, and on the residential gateway each new connection can leave from a different address. That is fine for public pages. For a login, a cart or anything with a session, use a sticky session (a dashboard setting) or a static ISP address, and one driver per address. The rotating vs sticky guide has the patterns.

When it still fails

  • A sign-in box, or a hang until timeout: nothing answered the challenge. The extension did not load, the relay is not running, or the Chrome BiDi first-page step was skipped.
  • ERR_TUNNEL_CONNECTION_FAILED or a 407: the proxy rejected the credentials. Copy them again from the dashboard, and check the allowlist still holds your current IP.
  • ERR_PROXY_CONNECTION_FAILED: wrong host or port, or the relay is not listening where the browser points.

What about selenium-wire?

Skip it. It used to be the standard answer, and it is archived: the README opens with “Selenium Wire is no longer being maintained”, the last release (5.1.0) shipped in October 2022 and the last commit landed in January 2024. It also worked by running its own interception proxy with its own certificate authority, decrypting every HTTPS page you loaded. An unmaintained package in that position is a bad trade when four maintained options exist.

Which one should you use?

  • Fixed server IP: the allowlist. Nothing to run, nothing to break.
  • Anything else, any browser: the local forwarding proxy. It is dull in the best way.
  • Firefox, or Chrome and you do not mind the first-page step: BiDi, with no extra process at all.
  • You already ship a Chrome extension: add the auth listener to it.

If you can switch tools, Playwright takes a proxy username and password as plain launch options and none of this page applies; the Playwright setup and the Playwright proxy guide have the code. Still stuck on a 407? That page walks through the usual causes, and the Selenium setup page covers timeouts and the errors you meet after login works.

Try it while you read

Top-ups start at $5.

One shared datacenter IP for 30 days is $2.10. A single gigabyte of residential is $5.50. The balance never expires.

Published

Filed under

Found a mistake? Tell us in Discord and we will fix the post.

The community layer

Stuck halfway through?

Paste the error in Discord. Someone has hit it before and the answer is usually one message long.

Join the Discord

4,200+monkeys in the Discord

  • Help from humans

    Post your error, get an answer. Usually in minutes, usually from someone who has hit the same wall.

  • A status bot that tells on us

    Pool health, incidents and maintenance posted automatically. Including the bad days.

  • Deals and free traffic

    Bonus GB drops, early access to new pools, and the occasional giveaway for a good bug report.

Join the Discord4,200+ monkeys, free to lurk