chrome.identity.launchWebAuthFlow errors in MV3 and the redirect URL that works
7 min readModerok team
What each chrome.identity.launchWebAuthFlow error message means in a Manifest V3 extension, the redirect URL Chrome accepts, and Firefox differences.
If chrome.identity.launchWebAuthFlow() keeps failing with Authorization page could not be loaded. or User interaction required., two things are almost always wrong: the redirect_uri is not on the origin Chrome generates from your extension ID, or the flow is running non-interactively. Start from this, with "permissions": ["identity"] in the manifest:
const redirectUri = chrome.identity.getRedirectURL(); // https://<extension-id>.chromiumapp.org/
const authUrl = new URL("https://provider.example/oauth/authorize");
authUrl.searchParams.set("client_id", CLIENT_ID);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("redirect_uri", redirectUri);
const finalUrl = await chrome.identity.launchWebAuthFlow({
url: authUrl.toString(),
interactive: true,
});
if (!finalUrl) throw new Error("no redirect URL"); // the return type is string | undefined
const code = new URL(finalUrl).searchParams.get("code");
Log redirectUri once and register that exact string with your provider. The rest of this post is what each error message means and what still breaks afterwards.
Chrome matches the redirect URL by origin
chrome.identity.getRedirectURL(path?) builds the URL for you. The identity API reference says "The generated URLs match the pattern https://<app-id>.chromiumapp.org/*", and for launchWebAuthFlow it says that "When the provider redirects to a URL matching the pattern https://<app-id>.chromiumapp.org/*" the window closes and "the final redirect URL will be passed to the callback function."
That * is a wildcard. In Chromium the check is redirect_url.Resolve("/") == default_origin, where default_origin comes from the pattern https://%s.chromiumapp.org/ filled in with your extension ID (identity_launch_web_auth_flow_function.cc). Path, query string and fragment are not compared at all, so https://<id>.chromiumapp.org/cb?code=abc and https://<id>.chromiumapp.org/#access_token=abc are both intercepted. The equality is against the whole origin, scheme included, so an http:// version of the same host is not. (The same function has a second branch that also matches a path prefix, but only against extra redirect URLs configured through the OAuthRedirectUrls enterprise pref, not the default origin.)
Your provider is stricter than Chrome. Most OAuth servers demand an exact, pre-registered redirect_uri, so what you register has to match what getRedirectURL() returned character for character, trailing slash included. If you want a path, pass it (getRedirectURL("oauth")) and register that.
Nothing is ever served from chromiumapp.org. MDN puts it plainly for the same API: "The browser extracts the result from the redirect URL without loading its response."
"Authorization page could not be loaded."
This is LOAD_FAILED in Chromium, raised from DidFinishNavigation in web_auth_flow.cc in two cases: the navigation in the primary main frame finished with a network error, or it finished with an HTTP response code of 400 or above. Two net errors are carved out and not treated as failures, an about:blank navigation and an HTTP 204.
The second case is the one that wastes afternoons. A wrong client_id, an unregistered redirect_uri or a bad scope usually makes the authorization server answer the first request with a 400, and the HTTP status is all Chrome reports: the provider's error body, which would name the parameter it disliked, never reaches you. Paste your authUrl into a normal tab, and if an error page renders there you have your answer.
A typo in the redirect domain lands here too. chromiumapps.org is not the intercepted origin, so Chrome attempts that navigation instead of stopping it, the load fails, and you get the generic message rather than anything about redirects.
Sub-frame failures are explicitly not a failure. The code comments that "Chrome shouldn't abort the auth flow if a navigation failed in a sub-frame", citing CAPTCHA iframes, and returns early for anything outside the primary main frame. Older bug reports blaming iframes for this error no longer apply.
"User interaction required." means the flow was silent
If you omit interactive, or set it to false, the reference says launchWebAuthFlow "will return with an error" when "the initial navigation does not complete the flow". The moment the provider's login page loads instead of redirecting, the flow aborts and rejects with the full message from identity_constants.h: "User interaction required. Try setting abortOnLoadForNonInteractive and timeoutMsForNonInteractive if multiple navigations are required, or if code is used for redirects in the authorization page after it's loaded."
Those two options (Chrome 113 and later) are the fix for providers that bounce you through JavaScript rather than a 302. abortOnLoadForNonInteractive is documented as "When set to true (default) the flow will terminate immediately after the page loads", and setting it to false "is useful for identity providers that use JavaScript to perform redirections after the page loads". timeoutMsForNonInteractive then bounds the wait and "Only has an effect if interactive is false". Chromium clamps it to one minute (kNonInteractiveMaxTimeout), so a larger value is not honoured.
A silent attempt that times out before the first page loads at all gets a different string, Authorization page load timed out.
The messages that are not about your code
Three more strings from identity_constants.h are worth recognising:
- "Identity API is disabled in incognito windows."
Run()checksprofile->IsOffTheRecord()immediately after checking that the profile is not shutting down, and fails before launching anything. An extension running in incognito split mode hits this from its incognito worker. - "The auth url has an invalid scheme. Only http:// and https:// schemes are allowed." A custom scheme in the
urlyou pass is rejected up front. - "Only one web auth flow is allowed at a time." The guard runs only for interactive flows, so a double-clicked sign-in button produces it.
And one that is easy to misread: "The user did not approve access." maps to WINDOW_CLOSED, whose only producer is WebContentsDestroyed(). It does not prove the user clicked deny: it is what you get whenever the auth window goes away, including the user closing it because the provider redirected somewhere that is not your chromiumapp.org origin. In that case Chrome intercepts nothing, the window simply stays open, and no rejection arrives until it is closed.
What still breaks after the fix
Your extension ID changes. Load an unpacked extension from a new directory and you get a new ID, a new chromiumapp.org subdomain, and an unregistered redirect. The key manifest field exists for this: "This value maintains the unique ID of an extension, or theme when it is loaded during development", and "Preserving a single ID is essential during development."
The service worker that started the flow. MV3 workers are torn down "After 30 seconds of inactivity" and also "When a single request, such as an event or API call, takes longer than 5 minutes to process", per the service worker lifecycle docs, and a human typing a password plus a 2FA code can exceed five minutes. Chrome 116 added a "Strong keepalive for Service Workers" for APIs that require user interaction, and identity.launchWebAuthFlow() is one of the four that release note names, so the worker is allowed "to take longer than 5 minutes on this task". State you left in a module-level variable is still gone after an unrelated teardown, so persist it to chrome.storage (see why your service worker keeps stopping).
The callback form swallows the message. launchWebAuthFlow returns a promise from Chrome 106, and a rejection carries these strings. Pass a callback instead and the message lands in chrome.runtime.lastError, discarded unless you read it inside the callback: the silent failure mode of every callback-style API.
Firefox differences
Firefox implements the same API with stricter rules about the redirect URL. MDN's identity docs say that "Starting with Firefox 75, you must use the redirect URL returned by identity.getRedirectURL(). Earlier versions allowed you to supply any redirect URL." The value is "a URL at a fixed domain name and a subdomain derived from the add-on's ID", not a chromiumapp.org address, so log it in Firefox and register that one too.
Because you cannot prove ownership of that generated domain, providers that require domain verification are a problem, and MDN documents an alternative: "Starting from Firefox 86, a loopback address with the format http://127.0.0.1/mozoauth2/[subdomain of URL returned by identity.getRedirectURL()] is permitted as a value for the redirect URL." The ID has to be pinned too, for the same reason as Chrome's key: MDN advises you "should probably set your extension's ID explicitly using the browser_specific_settings key (otherwise, each time you temporarily install the extension, you'll get a different redirect URL)."
Treat the non-interactive tuning options as Chrome-only: MDN's launchWebAuthFlow parameter list documents url, interactive and redirect_uri, and nothing else.
Knowing which failure your users hit
These messages are generic by design, and their distribution across your install base is not something you can guess from your own machine: a provider rate limit, a proxy blocking the authorization host and a user closing the window all arrive as the same bug report saying "login doesn't work". Recording the rejection message tells them apart. With the Moderok SDK that is Moderok.captureError(error, { api: "identity.launchWebAuthFlow" }) in the catch, which stores the message as an error event; the error tracking guide has the API.