Handling Auth Challenges with webRequestAuthProvider

Answer HTTP Basic, Digest and proxy authentication from an MV3 extension with onAuthRequired and asyncBlocking: the webRequestAuthProvider permission, loop protection and safe credential storage.

Published October 2, 2026 Updated October 2, 2026 8 min read
Table of Contents

Your MV2 extension supplied proxy credentials or HTTP Basic auth for an internal site through a blocking onAuthRequired listener. After migrating to MV3, the listener throws “Blocking webRequest is only allowed for policy-installed extensions”, the browser shows its own credentials dialog, and users behind the corporate proxy can no longer load anything. The fix is a separate permission that MV3 introduced for exactly this case. This guide belongs to cookies and webRequest observation.

Why auth is the exception to “no blocking”

MV3 removed blocking webRequest because a listener that can hold every request hostage makes the browser’s performance depend on the extension’s. Authentication challenges are different in kind: they are rare, they already block on a human typing into a dialog, and there is no declarative way to express “look up the right credentials for this proxy”. So Chrome kept one blocking path. With the webRequestAuthProvider permission, onAuthRequired accepts "asyncBlocking" and hands you a callback; the request waits until you call it. Everything else about the event — the challenger, the realm, the isProxy flag — is unchanged from MV2.

A proxy challenge answered by the extensionThe browser receives a 407 from the proxy, dispatches onAuthRequired to the service worker, the worker loads credentials from storage and calls the callback, and the browser retries the request with credentials.BrowserProxyService workerstorageCONNECT api.example.com407 Proxy-AuthenticateonAuthRequired(details, cb)request pausedget proxy creds{username, password}cb({authCredentials})retry with Proxy-Authorization
The request waits on your callback — call it exactly once, on every path.

Step-by-step: an MV3 auth provider

1. Request the permission and the hosts

1{
2  "permissions": ["webRequest", "webRequestAuthProvider", "storage"],
3  "host_permissions": ["<all_urls>"]   // proxy challenges can arrive for any destination
4}

Execution context: the manifest. webRequestAuthProvider has no install warning of its own; the host permissions do. For a server-auth helper limited to an internal domain, scope the hosts to that domain instead of <all_urls>. Chrome added the permission in version 108; earlier MV3 builds have no way to answer challenges asynchronously.

2. Register an asyncBlocking listener at the top level

 1// sw.js
 2chrome.webRequest.onAuthRequired.addListener(
 3  handleAuth,
 4  { urls: ["<all_urls>"] },
 5  ["asyncBlocking"]
 6);
 7
 8function handleAuth(details, callback) {
 9  answer(details)
10    .then((response) => callback(response))
11    .catch((err) => {
12      console.error("[auth] lookup failed", err);
13      callback({});                    // fall back to the browser's dialog
14    });
15}

Execution context: the service worker, registered synchronously so the challenge that wakes an evicted worker is delivered. The callback must be called exactly once on every path — success, failure, and “not mine”. A forgotten callback leaves the request pending until the browser gives up, which users experience as a page that never loads.

3. Decide what to answer

 1async function answer(d) {
 2  // Only answer challenges we know about
 3  if (d.isProxy) {
 4    const creds = await getProxyCredentials(d.challenger.host, d.challenger.port);
 5    return creds ? { authCredentials: creds } : {};
 6  }
 7  if (new URL(d.url).hostname.endsWith(".corp.example.com") && d.scheme === "basic") {
 8    const creds = await getServerCredentials(d.realm);
 9    return creds ? { authCredentials: creds } : {};
10  }
11  return {};                           // anything else: browser's normal behaviour
12}

Execution context: the service worker. {} lets the browser proceed as if no extension were listening — usually by showing its own dialog. { cancel: true } fails the request outright, which is right only when you are certain no credentials exist and a dialog would confuse the user. d.scheme is "basic" or "digest"; NTLM and Negotiate are handled by the OS and never reach the extension.

Choosing the response to an auth challengeDecision tree for onAuthRequired: proxy challenges get proxy credentials, known internal hosts get server credentials, a repeated challenge is cancelled, and everything else falls through to the browser.What kind of challenge is this?isProxyProxy credentialsby challenger hostauthCredentialsor {} if noneknown hostServer credentialsby realmauthCredentialsbasic or digestseen twiceCredentials rejectedloop guardcancel: trueor {} to promptotherNot oursno opinion{}browser decides
When in doubt, return an empty object — the browser's own dialog is a safe default.

4. Guard against infinite retry loops

If the stored password is wrong, the server challenges again, the extension answers again with the same wrong password, and the browser loops until it hits an internal limit — locking the account on many corporate systems. Track attempts per request.

 1const attempts = new Map();
 2
 3async function answer(d) {
 4  const n = (attempts.get(d.requestId) ?? 0) + 1;
 5  attempts.set(d.requestId, n);
 6  if (n > 1) {
 7    await markCredentialsBad(d.challenger.host);
 8    return {};                          // stop answering; let the user type
 9  }
10  // … lookup as before
11}
12
13chrome.webRequest.onCompleted.addListener((d) => attempts.delete(d.requestId), { urls: ["<all_urls>"] });
14chrome.webRequest.onErrorOccurred.addListener((d) => attempts.delete(d.requestId), { urls: ["<all_urls>"] });

Execution context: the service worker. The requestId is stable across auth retries of the same request, which is exactly what makes this guard work. If the worker is evicted mid-retry the map is lost; a persistent “credentials bad” flag in storage keeps the guard effective across that edge case.

5. Store credentials where they belong

 1async function getProxyCredentials(host, port) {
 2  const { proxyCreds = {} } = await chrome.storage.session.get("proxyCreds");
 3  return proxyCreds[`${host}:${port}`] ?? null;
 4}
 5
 6export async function saveProxyCredentials(host, port, username, password) {
 7  const { proxyCreds = {} } = await chrome.storage.session.get("proxyCreds");
 8  proxyCreds[`${host}:${port}`] = { username, password };
 9  await chrome.storage.session.set({ proxyCreds });
10}

Execution context: the service worker, called from the options page via a message. chrome.storage.session keeps the password in memory only and clears it at browser exit, so users re-enter it once per session — the same behaviour as the browser’s own proxy dialog. Persisting passwords in chrome.storage.local writes them to disk in plain text; if you must persist, encrypt with a key derived from something the user provides, as described in encrypting sensitive data in chrome.storage. Enterprise deployments should prefer credentials delivered by policy through chrome.storage.managed.

6. Give users a way to see and clear what you answer with

An extension that silently supplies credentials is convenient right up to the moment a password changes and every request starts failing. Show the stored entries — host, port, username, never the password — on the options page with a “forget” button per row, and surface the “credentials bad” flag from step 4 as a visible warning rather than a console line.

1// options.js
2const { proxyCreds = {} } = await chrome.runtime.sendMessage({ type: "auth:list" });
3for (const [hostPort, { username }] of Object.entries(proxyCreds)) {
4  renderRow(hostPort, username, () =>
5    chrome.runtime.sendMessage({ type: "auth:forget", hostPort }));
6}

Execution context: the options page, which asks the worker rather than reading chrome.storage.session directly — session storage is readable from extension pages by default, but routing through the worker keeps one code path responsible for redacting passwords before they reach any UI. Firefox and Chrome behave identically here; on Safari the whole feature is absent, so hide the section when onAuthRequired is not available.

This is also the place to explain why the extension answers proxy challenges at all. Users who did not install it themselves — common in managed fleets — will otherwise assume the credentials dialog vanished because of a browser bug, and IT will spend an afternoon finding the cause.

Cross-browser variation

  • Chrome / Edge: webRequestAuthProvider plus "asyncBlocking" from Chrome 108. A plain "blocking" listener still throws for store-installed extensions.
  • Firefox: blocking webRequest remains in MV3, so register with ["blocking"] and return a promise that resolves to the BlockingResponse. webRequestAuthProvider is accepted and ignored. Firefox also supports the proxy API for configuring the proxy itself.
  • Safari: does not dispatch onAuthRequired to extensions. Proxy and server authentication are handled entirely by the system; there is no extension-level workaround.
1// Portable registration
2const isFirefox = typeof browser !== "undefined" && browser.runtime.getURL("").startsWith("moz-extension:");
3if (isFirefox) {
4  browser.webRequest.onAuthRequired.addListener((d) => answer(d), { urls: ["<all_urls>"] }, ["blocking"]);
5} else {
6  chrome.webRequest.onAuthRequired.addListener(handleAuth, { urls: ["<all_urls>"] }, ["asyncBlocking"]);
7}

Execution context: the background context in each browser. Checking the extension URL scheme is a reliable engine test that does not depend on the user agent string.

Auth challenge support by engineHow Chrome, Firefox and Safari let an MV3 extension respond to proxy and server authentication challenges.AspectChromeFirefoxSafariRegistrationasyncBlockingblocking + promiseNot dispatchedPermissionwebRequestAuthProviderwebRequest—Proxy challengesYesYesSystem onlyServer Basic/DigestYesYesSystem only
Two engines, two registration styles; the decision logic can be shared.

Verification

  1. Point the browser at an authenticating proxy (a local mitmproxy --proxyauth user:pass works) and save matching credentials through your options page.
  2. Load any HTTPS page. It should load without a credentials dialog.
  3. Change the saved password to a wrong value and reload. You should see exactly one browser dialog — not a hang, and not a burst of failed attempts in the proxy log.
  4. In the service worker console, confirm one onAuthRequired dispatch per new connection and a “credentials bad” flag after the wrong-password test.

FAQ

Do I still need webRequest in permissions?

Yes. webRequestAuthProvider extends webRequest; it does not replace it. Without webRequest the chrome.webRequest namespace does not exist.

Can I use this to block requests by cancelling every challenge?

Only challenged requests reach the listener, so no. Blocking in MV3 is the job of declarativeNetRequest rules.

Why does the browser still show a dialog on the first request after startup?

Session storage is empty until the user re-enters credentials, so the extension returns {} and the browser prompts. If that is unacceptable, deliver credentials by enterprise policy or persist them encrypted.

Other Core APIs & Cross-Browser Data Management Resources