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.
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.
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:
- 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.
- Wildcard subdomain. Providers that accept
https://*.extensions.allizom.org/oauth2solve Firefox completely. Google does not accept wildcards; many smaller providers and most self-hosted servers do. - A callback page you host. Register
https://auth.example.com/ext-callbackwith the provider, and have that page immediately redirect to theredirect_uripassed to it instate. The extension still receives the final navigation.
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.
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 setkey. 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
curland conclude the URL is wrong.
Verification
- 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. - Load the extension unpacked from a different directory. With
keyset, the Chrome value should be unchanged; without it, the ID and therefore the URL will differ. - Reinstall the Firefox add-on and log the value again. It should change, confirming that an exact registration cannot work for end users.
- Trigger the flow with a deliberately wrong
redirect_uriand confirm your logging surfaces the provider’serror_descriptionrather 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.
Related
- Implementing OAuth 2.0 with launchWebAuthFlow — the flow this error interrupts.
- Choosing between getAuthToken and launchWebAuthFlow — the path that has no redirect URL to register.
- Refreshing and storing access tokens securely — what happens once the callback finally lands.
- Identity & OAuth Authentication — the guide this page belongs to.