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.
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.
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.
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:
webRequestAuthProviderplus"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 theBlockingResponse.webRequestAuthProvideris accepted and ignored. Firefox also supports theproxyAPI for configuring the proxy itself. - Safari: does not dispatch
onAuthRequiredto 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.
Verification
- Point the browser at an authenticating proxy (a local
mitmproxy --proxyauth user:passworks) and save matching credentials through your options page. - Load any HTTPS page. It should load without a credentials dialog.
- 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.
- In the service worker console, confirm one
onAuthRequireddispatch 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.
Related
- Observing network requests with webRequest in MV3 — the non-blocking listeners that share this namespace.
- Configuring extensions with enterprise managed storage — delivering credentials by policy.
- Encrypting sensitive data in chrome.storage — if credentials must persist.
- Cookies and webRequest observation — the parent topic.