Selenium proxy authentication after Manifest V3

    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.

    What actually broke, in order

    The Manifest V2 extension stopped loading

    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.

    The Manifest V3 rewrite loads, and still misses the challenge

    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.

    The symptom

    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.

    Option 1: the WebDriver BiDi auth handler

    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.

    Option 2: CDP Fetch.authRequired, and the trap in execute_cdp_cmd

    Before 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:

    1. Fetch.enable with handleAuthRequests: true.
    2. Chrome emits Fetch.authRequired with a requestId and an authChallenge.
    3. You answer with Fetch.continueWithAuth, passing an authChallengeResponse whose response is ProvideCredentials along with username and password.
    4. Every non-auth interception you enabled must then be released with 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.

    Option 3: selenium-wire, and why it is not the answer any more

    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.

    Option 4: a local upstream proxy that holds the credentials

    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.

    Option 5: remove authentication from the problem

    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.

    Option 6: an MV3 extension, built so it actually survives

    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.

    Six ways to do Selenium proxy authentication, compared

    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.

    Rotation and per-session identity in Selenium

    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:

    • A fresh driver per identity. Correct and expensive. Every launch pays browser startup, and you get complete isolation of cookies, cache and storage for free.
    • A fresh profile directory per identity, reusing the driver. Cheaper on paper, but you still have to relaunch Chrome for --proxy-server to change, so the saving is smaller than it looks.
    • A pinned sticky port per worker. The pattern that scales. Point worker N at a fixed port in the 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.

    The errors, and what each one tells you

    • 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.
    • A hung 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.

    Version matrix

    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.

    Frequently asked questions

    Does Selenium support proxy authentication natively?

    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.

    Why did my Chrome proxy auth extension stop working?

    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.

    How do I fix a 407 proxy authentication required error in Selenium?

    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.

    Should I still use selenium-wire in 2026?

    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.

    Can I use a different proxy for each Selenium session?

    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.

    Start routing

    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.