Chrome 138 disabled the MV2 auth extension and MV3 service workers miss the challenge. Six ways to do Selenium proxy authentication, compared in a table.
· 16 min read · Integrations
Written against Chrome 151 and Selenium 4.46.0 on 2026-08-06. Versions matter more than usual here, and the date on this page is part of the answer. Full matrix at the bottom.
Selenium proxy authentication is the one part of browser automation that has
no API. There is no driver.set_proxy_credentials(). There never was.
WebDriver's proxy capability carries a host and a port and nothing else, so
every working approach to Selenium proxy authentication is a workaround that
lives somewhere other than Selenium — in the browser, in an extension, in the
DevTools protocol, or in a second proxy you run yourself. Half the confusion
in search results for this topic comes from people looking for the flag. The
flag does not exist.
The workaround that most tutorials still publish — build a Chrome extension on
the fly, hand it credentials, load it with add_extension() — stopped working
in two separate ways, at two separate times. Both are documented, both are
fixable, and neither is what the top results say.
The classic recipe zips a manifest.json with "manifest_version": 2 plus a
background.js that calls chrome.proxy.settings.set() and registers
chrome.webRequest.onAuthRequired. That extension no longer loads.
Google's
Manifest V2 support timeline
records the two dates that matter: on 2025-07-24, with Chrome 138, "all
users on all channels of Chrome have now Manifest V2 extensions disabled," and
the ExtensionManifestV2Availability enterprise policy — the last escape
hatch — was removed with Chrome 139. Chrome Stable is on 151 as of
2026-08-03. If your recipe emits "manifest_version": 2, it has been dead for
more than a year and the failure is silent: the browser starts, the extension
is not there, and the first proxied navigation gets a 407.
Bumping the manifest to version 3 is not sufficient, and this is the part the existing pages get wrong. MV3 replaces the persistent background page with an extension service worker, and that service worker is terminated after 30 seconds of inactivity. Chrome revives it when an event arrives — but only for listeners it already knows about.
The Chrome migration guide is explicit about the consequence: "Registering a listener asynchronously (for example inside a promise or callback) is not guaranteed to work in Manifest V3," because "the service worker will be reinitialized when the event is dispatched. This means that when the event fires, the listeners will not be registered (since they are added asynchronously), and the event will be missed."
Almost every generated auth extension in the wild registers
chrome.webRequest.onAuthRequired after an await on
chrome.proxy.settings.set(). That is asynchronous registration. It works on
the first navigation, because the worker is still warm from startup. It stops
working the moment your script idles — a long page parse, a time.sleep, a
slow database write between fetches — because by then the worker has been
torn down and the revived instance has no auth listener.
There is a second MV3 constraint underneath it: "webRequestBlocking" is
no longer available to most extensions,
and supplying credentials from onAuthRequired now requires the
"webRequestAuthProvider" permission plus 'asyncBlocking' in the
extraInfoSpec. An extension missing that permission registers cleanly and
then does nothing useful.
When the listener is not there, Chrome falls back to its native credential dialog. That dialog is a browser-level UI element, so nothing in the page DOM changes, no exception is raised, and the WebDriver command sits until the page-load timeout expires. From the outside it looks like a slow site. In the logs it looks like nothing at all.
That mechanism is drawn from Chrome's own service-worker lifecycle and migration documentation, not from a recorded run, so reproduce it on your own stack before you plan around it. The recipe is three steps: navigate once through the proxy, idle for 60 seconds with no browser activity, navigate again. A warm worker hides the bug. A cold one puts it on the second navigation.
This is where Selenium is going, it is cross-browser, and it is the option the top-ranking pages cover worst — most of them still lead with the extension. It also carries one caveat for proxy work specifically that none of those pages mention. That caveat is at the end of this section and it decides whether you should build on this option today.
WebDriver BiDi has a network module with an AUTH_REQUIRED intercept phase
and a continueWithAuth command. The Python binding wraps both in a
one-liner. Enable BiDi on the options object, then register the handler.
from selenium import webdriver
options = webdriver.ChromeOptions()
options.enable_bidi = True
options.add_argument("--proxy-server=http://gw.anonedge.com:823")
driver = webdriver.Chrome(options=options)
callback_id = driver.network.add_auth_handler("YOUR_LOGIN", "YOUR_PASSWORD")
try:
driver.get("https://api.ipify.org/?format=json")
print(driver.find_element("tag name", "pre").text)
finally:
driver.network.remove_auth_handler(callback_id)
driver.quit()
add_auth_handler returns a callback id; hand it back to
remove_auth_handler when you are done, or the handler outlives the flow you
wanted it scoped to. The equivalent in JavaScript registers the intercept
explicitly:
const network = await Network(driver)
await network.addIntercept(new AddInterceptParameters(InterceptPhase.AUTH_REQUIRED))
await network.authRequired(async (event) => {
await network.continueWithAuth(event.request.request, 'YOUR_LOGIN', 'YOUR_PASSWORD')
})
Two caveats, stated plainly.
First, --proxy-server is a Chromium launch argument, which means the proxy
is set for the whole browser process. Per-session proxies need one browser per
session, which is the subject of the rotation section below.
Second, and this is the one that matters: the BiDi auth path is documented
against origin authentication, not proxy authentication. Selenium's
worked example for the network module
is a 401 basic-auth page, and neither that page nor the specification text
states that the AUTH_REQUIRED phase covers a 407 proxy challenge.
There is contrary evidence. Puppeteer drives the same Chrome BiDi
implementation, and it carries an open, confirmed bug —
puppeteer/puppeteer#14499,
filed 2025-12-10 — in which a proxy credential over BiDi produces this: "I
noticed network.authRequired event never comes in and
browsingContext.navigate ends in a timeout." The same configuration works
over CDP. The issue was still open at the time of writing.
So prove this option against your own gateway before you build on it, and keep a fallback configured. If the handler is silently not firing, the failure is indistinguishable from the Manifest V3 failure above: a navigation that hangs to timeout with no exception raised.
Fetch.authRequired, and the trap in execute_cdp_cmdBefore BiDi, the answer was the Chrome DevTools Protocol, and for proxy
challenges specifically it is still the better-documented of the two. CDP is
explicit where BiDi is silent: the AuthChallenge object carries a source
field whose allowed values are
Server or Proxy,
so a 407 is part of the contract rather than an assumption. The mechanism:
Fetch.enable with handleAuthRequests: true.Fetch.authRequired with a requestId and an
authChallenge.Fetch.continueWithAuth, passing an
authChallengeResponse whose response is ProvideCredentials along with
username and password.Fetch.continueRequest, or the browser stalls on its own paused requests.Here is the trap. driver.execute_cdp_cmd() sends a command and returns its
result. It does not subscribe to events. Fetch.authRequired is an event, so
a script built only from execute_cdp_cmd can turn interception on and then
has no way to hear the challenge — which is exactly how you end up with a
browser that pauses every request and never resumes any of them. You need a
bidirectional connection to the DevTools endpoint to use this at all.
Selenium is also moving off CDP deliberately.
CDP support for Firefox was removed in Selenium 4.29.0
after two versions of deprecation, and the project's stated direction is that
CDP is legacy and BiDi is the replacement. That makes this a transitional
option: better documented for 407 today, on a deprecation path tomorrow, and
Chromium-only in either case.
selenium-wire runs a local man-in-the-middle proxy between the browser and
the upstream, which lets it hold upstream credentials and expose request and
response bodies to your test code. It solved this problem well for years.
It is no longer maintained. The last release on
PyPI is 5.1.0, published
2022-10-15, and the GitHub repository
wkeeling/selenium-wire carries
the banner "This repository was archived by the owner on Jan 3, 2024. It is now
read-only" alongside 163 open issues. An archived MITM proxy that sits in the
TLS path of every request your automation makes, tracking a browser that ships
a new major version every four weeks, is not a dependency to add in 2026.
If you already run it and it works, it works. Do not start here.
Move authentication out of the browser entirely. Run an unauthenticated proxy
on 127.0.0.1 that forwards to the authenticated gateway, and point Chrome at
localhost. Chrome never sees a challenge, so there is nothing to answer.
tinyproxy is the smallest thing that does this. It relays CONNECT to the
upstream without terminating TLS, so there is no certificate to install:
# tinyproxy.conf
Port 8888
Listen 127.0.0.1
Allow 127.0.0.1
Timeout 600
Upstream http YOUR_LOGIN:[email protected]:823
tinyproxy -d -c ./tinyproxy.conf
options = webdriver.ChromeOptions()
options.add_argument("--proxy-server=http://127.0.0.1:8888")
driver = webdriver.Chrome(options=options)
mitmproxy does the same job and gives you request inspection, at the cost of terminating TLS — which means installing its CA into the Chrome profile:
mitmdump --listen-host 127.0.0.1 --listen-port 8888 \
--mode upstream:http://gw.anonedge.com:823 \
--upstream-auth YOUR_LOGIN:YOUR_PASSWORD
This option has one property the others do not: it works identically for every
tool on the machine, not just Selenium. curl, requests, a Java client and
a headless browser all point at the same local port. That also makes it the
one option that composes cleanly with
environment-level proxy configuration —
point HTTP_PROXY at the local listener and every tool that honors those
variables follows. In containerized runs it is a sidecar, and the credential
lives in exactly one place instead of in every worker's environment.
The cost is a process to supervise and one extra hop of latency per request. For per-session identity you run one local listener per sticky upstream port, which is cheap but is another thing to allocate.
If your egress addresses are stable, register them and drop credentials
altogether. Chrome gets --proxy-server=http://gw.anonedge.com:823, the
gateway recognizes the source address, and there is no challenge to intercept.
This is the lowest-complexity option by a distance, and it is the right one
for a fixed CI runner pool or a static NAT gateway.
It has two real costs. Ephemeral egress — autoscaled runners, developer
laptops, anything behind a rotating cloud NAT — makes the list a maintenance
burden that fails in the worst way, with the whole fleet getting 407 after an
infrastructure change nobody connected to the proxy. And any targeting you
encode in the username is unavailable when there is no username, so per-job geo
selection has to move somewhere else. The full trade-off is set out in
remove auth entirely with whitelisting;
read the targeting reference before you
commit to this one.
You can still make the extension route work. It is the most fragile of the six, and if you are going to do it, do it with the two constraints from the breakage section applied.
manifest.json:
{
"manifest_version": 3,
"name": "proxy-auth",
"version": "1.0",
"permissions": ["proxy", "webRequest", "webRequestAuthProvider"],
"host_permissions": ["<all_urls>"],
"background": { "service_worker": "background.js" }
}
background.js — note that the listener is registered at the top level of the
script, synchronously, before anything is awaited:
chrome.webRequest.onAuthRequired.addListener(
(details, callback) => {
callback({ authCredentials: { username: "YOUR_LOGIN", password: "YOUR_PASSWORD" } });
},
{ urls: ["<all_urls>"] },
["asyncBlocking"]
);
chrome.proxy.settings.set({
value: {
mode: "fixed_servers",
rules: {
singleProxy: { scheme: "http", host: "gw.anonedge.com", port: 823 },
bypassList: ["localhost", "127.0.0.1"]
}
},
scope: "regular"
});
Load it as an unpacked directory rather than a .crx, because packed-extension
loading has its own moving requirements. Then test it by idling for a minute
between navigations — a warm service worker will hide the bug you are trying
to avoid.
| Option | Setup cost | Per-request overhead | Headless | Survives a Chrome update | Per-session credentials |
|---|---|---|---|---|---|
BiDi auth handler (prove 407 first) |
Two lines | None | Yes | Most likely — spec-backed | Per browser instance |
CDP Fetch |
High (needs an event loop) | Interception on every request | Yes | Deprecated in Selenium | Per browser instance |
| selenium-wire | Low | Full MITM, TLS re-signing | Yes | No — archived since 2024 | Yes |
| Local upstream proxy | One process | One extra hop | Yes | Yes — no browser coupling | One listener per session |
| IP whitelisting | Registration only | None | Yes | Yes | No credentials at all |
| MV3 extension | Build and zip per run | Listener wake per challenge | Yes | Fragile by design | Per browser instance |
If you want one recommendation: run a local upstream proxy for anything at
scale or in containers, and use IP whitelisting where your egress is genuinely
static. Neither is coupled to the browser, which is the whole reason both keep
working across Chrome releases. Try the BiDi auth handler for tests and small
jobs, but prove it answers a 407 against your own gateway before you depend
on it, and fall back to CDP Fetch if it does not. Do not start new work on
selenium-wire.
Selenium's proxy is a process-level setting. Chrome takes --proxy-server at
launch and there is no supported way to change it in a running browser, so
"rotate the proxy" always means "get a new browser or a new upstream port".
Three patterns, with what each costs:
--proxy-server to change,
so the saving is smaller than it looks.10000-20000 range and it holds one exit for the
life of that worker. Two workers on the same port share an address, so your
port allocator is your session allocator. The
sticky port range and the
rotating ports on 823 and 824
are documented separately.For a rotating exit per request you do not need a new browser at all — point
at gw.anonedge.com:823 and each connection draws a fresh address. What you
cannot do is mix the two in one browser process.
This is the one place where the other major automation library is genuinely better shaped for the job: Playwright puts the proxy on the browser context rather than the process, so a pool of identities costs contexts instead of browsers. If you are choosing rather than maintaining, read the Playwright equivalent before you commit to a Selenium-shaped design.
407 on every single request. Credentials are not reaching the gateway
at all. The extension did not load, the auth handler was never registered,
or the username is wrong. Test the credential outside the browser first:
curl -x "http://LOGIN:[email protected]:823" https://api.ipify.org/.
If curl works and Chrome does not, the problem is the workaround, not the
proxy.407 after a period of working. The Manifest V3 idle-termination
pattern. Move the listener registration to the top level, or switch options.driver.get() with no exception. Chrome is showing the native
credential dialog behind your automation. Same cause.407 THREADS_EXHAUSTED. Not an auth failure. You hit the concurrent
connection ceiling on your plan, and browsers open a lot of sockets per
page. Details on the
threads and concurrency page.400 NO_RAY. No exits matched your targeting filter. Widen it.ERR_TUNNEL_CONNECTION_FAILED in the page, not the log. Chrome could
not complete CONNECT. Usually a wrong port or a blocked destination port —
check the
gateway troubleshooting notes.| Component | Version | Source |
|---|---|---|
| Chrome Stable | 151.0.7922.76 | Chromium Dash, 2026-08-03 |
| ChromeDriver | Matched to the installed Chrome major | Selenium Manager resolves it |
| Selenium (Python) | 4.46.0 | PyPI, released 2026-07-11 |
| selenium-webdriver (Node) | 4.46.0 | npm |
| selenium-wire | 5.1.0 | PyPI, released 2022-10-15; repo archived |
| Manifest V2 disabled everywhere | Chrome 138 | 2025-07-24 |
ExtensionManifestV2Availability removed |
Chrome 139 | Chrome for Developers |
This matrix records published version numbers and where each came from, dated 2026-08-06. It is not the output of a test run, and it is the part of this page that goes stale first. Re-check it before you rely on a version boundary, and re-check this page's date against your own Chrome.
No. The WebDriver proxy capability carries a host and a port, and there is
no field for credentials in any binding. Every method that works — the BiDi
auth handler, a DevTools intercept, a local forwarding proxy, an extension, or
IP whitelisting — answers the challenge outside Selenium. Searching for a flag
that sets a username and password is the most common wasted hour on this
topic.
Two separate causes. If it emits "manifest_version": 2, Chrome stopped
loading it: Manifest V2 was disabled for all users with Chrome 138 in July
2025 and the enterprise override was removed with Chrome 139. If it is
Manifest V3 and works at first but fails after your script idles, the
extension service worker was terminated after 30 seconds and the revived
instance had no onAuthRequired listener because the listener was registered
asynchronously.
First prove the credential works outside the browser with a curl -x request
through the same host and port. If curl succeeds, the credential is fine and
the browser-side workaround is at fault: register a BiDi auth handler, or
point Chrome at a local unauthenticated proxy that holds the credentials
upstream. If curl also gets a 407, the username, password or gateway host is
wrong.
Not for new work. Its last PyPI release was 5.1.0 in October 2022, the GitHub repository has been archived and read-only since January 2024, and it carries 163 open issues. It intercepts TLS for every request your automation makes, which is a large surface to run unmaintained against a browser that ships a major version every four weeks. Existing setups that work can stay; do not build on it.
Yes, but only one per browser process, because --proxy-server is read at
launch and cannot be changed afterward. Run one driver per identity and pin
each to its own sticky port in the 10000-20000 range, so worker N always exits
from the same address. For per-request rotation instead, point every worker at
the rotating port and let the gateway hand out a new address per connection.
Get the credential pair first and prove it with curl before you touch the
browser. Start routing on a pay-as-you-go account,
take the login and password from
the quickstart, and point Chrome
at gw.anonedge.com:823 with the BiDi auth handler above. Rates are per
gigabyte with no expiry — check the per-GB rates.