Fixing OAuth Redirect URI Mismatches

Why getRedirectURL returns a different origin in every browser, how to register all of them, and what to do when a provider refuses per-installation Firefox UUIDs.

Published August 1, 2026 Updated August 1, 2026 8 min read
Table of Contents

The provider returns redirect_uri_mismatch and the URL in the error looks exactly like the one you registered. It is not: the extension ID changed when you loaded it unpacked, or you are testing in Firefox where the redirect host is a per-installation UUID that did not exist when you filled in the provider’s form. This is the single most common blocker in extension authentication, and it is a configuration problem rather than a code problem. This page belongs to Identity & OAuth Authentication and works through each engine’s redirect scheme and the workarounds when a provider will not accept it.

Root cause: the redirect host is generated, not chosen

A web app picks its own callback URL. An extension cannot — there is no origin it controls that the provider can navigate to. Instead each engine synthesises a URL that never resolves over the network and that the browser intercepts during navigation. chrome.identity.getRedirectURL() returns it, and the value is derived from an identifier that differs by engine and, in Firefox, by installation.

What each engine derives the redirect host fromChrome builds it from the stable extension ID, Firefox from a per-installation UUID, and Safari from the containing app's identifier.Chrome / Edgeextension IDFirefoxper-install UUIDSafaricontainer app IDgetRedirectURL() returns<id>.chromiumapp.orgstable once published<uuid>.extensions.allizom.orgdifferent per userapp-derived hoststable per build
Only Chrome's value is the same on every machine, which is why Firefox is where this error usually appears.

None of these hosts serve content. The browser watches the auth window’s navigations and, the moment one targets that host, cancels the load and resolves the promise with the URL. That is why the domain not existing is irrelevant, and why you can append any path you like.

Step 1. Print the real value from every engine

Do not construct the URL by hand. Ask the API, in each browser you support, and paste what it prints.

1// Run in the service worker console of each engine you ship to.
2console.log(chrome.identity.getRedirectURL());          // no path
3console.log(chrome.identity.getRedirectURL("oauth2"));  // with a path segment

Execution context: Service worker or any extension page; getRedirectURL is synchronous and needs no permission beyond identity being declared. Chrome prints https://<32-char-id>.chromiumapp.org/oauth2. Firefox prints a URL built from the installation’s UUID, which changes if the user removes and reinstalls the add-on. Safari prints a host derived from the containing app. Passing the same path string everywhere keeps the tail of the URL identical, which makes the provider’s list easier to read.

Step 2. Pin the extension ID during development

In Chrome, an unpacked extension gets an ID derived from its directory path, so it changes on every teammate’s machine and every time the folder moves. Add a key to the manifest and the ID becomes deterministic.

1{
2  "manifest_version": 3,
3  "name": "Sync Companion",
4  // The base64 public key from the .pem generated when you first packed the extension,
5  // or copied from the item's page in the developer dashboard once published.
6  "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA…",
7  "permissions": ["identity"]
8}

Execution context: Manifest, read at load time. With key present, Chrome derives the same extension ID — and therefore the same chromiumapp.org host — on every machine and in every checkout, so one registration covers the whole team and CI. Firefox ignores key and instead honours browser_specific_settings.gecko.id for the add-on ID, but that ID is not what the redirect host is built from, so the Firefox redirect stays per-installation regardless. Ship the key in your development manifest and strip it for the store upload, since the store assigns the ID itself.

Step 3. Register every host with the provider

Providers differ in how much of this they tolerate. Work through them in order of preference:

  1. Exact URLs, one per engine. Register the Chrome value and your own Firefox and Safari values. This works for a team but not for real Firefox users, whose UUIDs you cannot know.
  2. Wildcard subdomain. Providers that accept https://*.extensions.allizom.org/oauth2 solve Firefox completely. Google does not accept wildcards; many smaller providers and most self-hosted servers do.
  3. A callback page you host. Register https://auth.example.com/ext-callback with the provider, and have that page immediately redirect to the redirect_uri passed to it in state. The extension still receives the final navigation.
Choosing a registration strategy from what the provider acceptsExact per-engine URLs work for a team, a wildcard subdomain solves Firefox for real users, and a hosted callback covers providers that allow neither.Does the provider accept wildcard redirect hosts?YesWildcard registration*.extensions.allizom.orgCovers every Firefox usernothing else to runNo, exact onlyRegister each engine's URLChrome and Safari are stableFirefox users still failtheir UUID is unknowableNeither worksHosted callback bounceyour server 302s onwardA server you must operateand verify state on both hops
Work down the list — each step costs more than the one above it.
 1// Option 3, the portable fallback: your own page bounces to the browser-generated URL.
 2const browserRedirect = chrome.identity.getRedirectURL("oauth2");
 3const state = crypto.randomUUID();
 4
 5// Stash the real destination so the hosted page can read it back.
 6await fetch("https://auth.example.com/ext-callback/register", {
 7  method: "POST",
 8  headers: { "Content-Type": "application/json" },
 9  body: JSON.stringify({ state, redirect: browserRedirect }),
10});
11
12const url = new URL("https://provider.example.com/oauth2/authorize");
13url.search = new URLSearchParams({
14  client_id: CLIENT_ID,
15  response_type: "code",
16  redirect_uri: "https://auth.example.com/ext-callback",   // the one the provider knows
17  state,
18  code_challenge: challenge,
19  code_challenge_method: "S256",
20}).toString();
21
22const returned = await chrome.identity.launchWebAuthFlow({ url: url.href, interactive: true });

Execution context: Service worker; the fetch requires https://auth.example.com/* in host_permissions. The hosted page looks up state, verifies it, and issues a 302 to the stored browser-generated URL with the code appended — the extension’s promise then resolves as normal. This adds a server you must run, so reach for it only when the provider refuses both exact per-engine registration and wildcards. Verify the state on both hops: the hosted page is now part of your trust boundary.

Step 4. Read the error the provider actually sent

A mismatch can arrive in two very different ways, and confusing them wastes hours.

Where a mismatch surfacesA rejected redirect_uri stops the flow at the provider's authorize endpoint, before any window navigates back, so launchWebAuthFlow never resolves with a code.WorkerAuth windowProviderlaunchWebAuthFlow(authorize URL)GET /authorize?redirect_uri=…400 page: redirect_uri_mismatchno redirect happensuser closes the windowrejects as a cancellation
If the error is rendered as a page in the auth window, the provider rejected it — nothing in your extension is wrong yet.

The first form is an error page rendered inside the auth window. No redirect happens, so the extension eventually sees a cancellation when the user closes it — the real message is only visible to whoever is watching the window. The second form is a redirect back to your callback carrying ?error=invalid_request, which your code can read directly. Handle both: log the resolved URL’s full query string, and tell testers to read the auth window before closing it.

 1const returned = await chrome.identity.launchWebAuthFlow({ url: url.href, interactive: true });
 2const params = new URL(returned).searchParams;
 3
 4if (params.has("error")) {
 5  console.error("provider rejected the request", {
 6    error: params.get("error"),
 7    description: params.get("error_description"),
 8    sentRedirect: chrome.identity.getRedirectURL("oauth2"),
 9  });
10  throw new Error(`auth-${params.get("error")}`);
11}

Execution context: Service worker. Logging the value of getRedirectURL() alongside the provider’s complaint is what turns a support thread into a five-second diagnosis — the two strings sit next to each other in the log and the difference is visible. Chrome, Firefox and Safari all resolve with the complete URL including its query string, so this parsing is identical everywhere.

Cross-browser variation

  • Chrome/Edge: host is <extension-id>.chromiumapp.org. Stable in the store, path-derived when unpacked unless you set key. Edge uses the same scheme with its own extension ID, so an extension published to both stores needs both registered.
  • Firefox: host is built from a UUID generated at install time, so every user has a different one. Only a wildcard registration or a hosted callback covers real users. The UUID also changes when the profile is recreated, which is why it appears to change during testing.
  • Safari: host is derived from the containing app, so it is stable per build but changes if you rename the app bundle. Test the value after any packaging change.
  • All engines: the redirect host never resolves in DNS. Do not test it with curl and conclude the URL is wrong.

Verification

  1. Log chrome.identity.getRedirectURL("oauth2") in each engine and diff the three strings against the provider’s registered list, character by character — a missing trailing slash is a mismatch.
  2. Load the extension unpacked from a different directory. With key set, the Chrome value should be unchanged; without it, the ID and therefore the URL will differ.
  3. Reinstall the Firefox add-on and log the value again. It should change, confirming that an exact registration cannot work for end users.
  4. Trigger the flow with a deliberately wrong redirect_uri and confirm your logging surfaces the provider’s error_description rather than a bare cancellation.

FAQ

Can I use http://localhost as the redirect?

Not with launchWebAuthFlow — the browser only intercepts its own generated host. Providers that require a loopback redirect are assuming a native app with a local HTTP server, which an extension does not have.

Does the path after the host matter?

Only in that it must match what you registered. The browser intercepts on host, so any path works, but the provider compares the full string.

Why did this start failing after publishing?

The published extension has a different ID from your unpacked build unless you set key to the published item’s key. Register the store ID’s host as well.

Is the code visible to anyone else in the redirect?

The URL is handled inside the browser and never leaves it, and the code is single-use and bound to your PKCE verifier. That binding is the reason the flow is safe even though the redirect host is predictable.

Other Core APIs & Cross-Browser Data Management Resources