OAuth in Firefox and Safari Extensions

Implement OAuth2 in Firefox and Safari web extensions without chrome.identity.getAuthToken — redirect URLs, PKCE, token storage and the differences from the Chrome flow.

Published September 18, 2026 Updated September 18, 2026 8 min read
Table of Contents

A Chrome extension can ask the browser for a Google token in one call. Firefox and Safari have no equivalent: getAuthToken does not exist, there is no browser-managed token cache, and the redirect URL your Chrome build registered with the provider will not match. Porting an authenticated extension therefore means implementing the flow properly — launchWebAuthFlow with PKCE, and your own token lifecycle. This guide is part of identity and OAuth authentication.

What changes across the three engines

Identity surface by engineAvailability of getAuthToken, launchWebAuthFlow, the redirect URL shape, token caching and refresh-token handling in Chrome, Firefox and Safari.CapabilityChrome / EdgeFirefoxSafariidentity.getAuthTokenGoogle accounts onlyNot availableNot availableidentity.launchWebAuthFlowYesYesYesgetRedirectURL() host<id>.chromiumapp.org<uuid>.extensions.allizom…<uuid>.extensionBrowser token cacheYesNoneNoneRefresh token handlingBrowser does itYoursYoursStable extension idkey in manifestbrowser_specific_settingsBundle identifier
The row that forces the rewrite is token caching — off Chrome, the refresh lifecycle is entirely yours.

Step-by-step

1. Pin a stable extension identity

The redirect URL is derived from the extension id. If the id changes between builds, every install needs the provider’s redirect list updated — so pin it before registering anything.

1{
2  "browser_specific_settings": {
3    "gecko": {
4      "id": "reader@example.com",        // Firefox: stable across builds
5      "strict_min_version": "121.0"
6    }
7  }
8}

Execution context: parsed at install by Firefox; Chrome ignores the key. Firefox still derives getRedirectURL() from a per-install UUID rather than this id, which is why step 2 registers a pattern rather than a literal.

2. Register the right redirect URL per engine

1const redirectUri = browser.identity.getRedirectURL();
2// Chrome  → https://<extension-id>.chromiumapp.org/
3// Firefox → https://<install-uuid>.extensions.allizom.org/
4// Safari  → <bundle-id>://

Execution context: any extension context. Print this during development on each target and register all of them with the provider. Firefox’s host contains a per-install UUID, so providers that require an exact redirect URL need a wildcard entry — and providers that refuse wildcards need a hosted redirect page you control that bounces back to the extension.

3. Build the authorisation URL with PKCE

Extensions are public clients: they cannot keep a secret. PKCE replaces the client secret with a per-request proof, and every major provider now requires it.

1async function pkcePair() {
2  const bytes = crypto.getRandomValues(new Uint8Array(32));
3  const verifier = btoa(String.fromCharCode(...bytes))
4    .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
5  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
6  const challenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
7    .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
8  return { verifier, challenge };
9}

Execution context: the background script — crypto.subtle is available in Firefox’s event page, Chrome’s service worker and Safari’s background context alike. Note it requires a secure context, which every extension page is.

4. Run the flow

 1async function signIn() {
 2  const { verifier, challenge } = await pkcePair();
 3  const state = crypto.randomUUID();
 4
 5  const authUrl = new URL("https://accounts.example.com/authorize");
 6  authUrl.search = new URLSearchParams({
 7    client_id: CLIENT_ID,
 8    redirect_uri: browser.identity.getRedirectURL(),
 9    response_type: "code",
10    scope: "openid profile offline_access",
11    code_challenge: challenge,
12    code_challenge_method: "S256",
13    state,
14  }).toString();
15
16  const returned = await browser.identity.launchWebAuthFlow({ url: authUrl.href, interactive: true });
17  const params = new URL(returned).searchParams;
18  if (params.get("state") !== state) throw new Error("state mismatch");
19  return exchangeCode(params.get("code"), verifier);
20}

Execution context: the background script, invoked from a user gesture. launchWebAuthFlow opens a browser-managed window and resolves with the final redirect URL; it rejects if the user closes it. The state check is not optional — it is what stops a crafted redirect injecting someone else’s code.

5. Exchange the code and own the refresh cycle

 1async function exchangeCode(code, verifier) {
 2  const res = await fetch("https://accounts.example.com/token", {
 3    method: "POST",
 4    headers: { "Content-Type": "application/x-www-form-urlencoded" },
 5    body: new URLSearchParams({
 6      grant_type: "authorization_code",
 7      client_id: CLIENT_ID,
 8      code,
 9      code_verifier: verifier,
10      redirect_uri: browser.identity.getRedirectURL(),
11    }),
12  });
13  if (!res.ok) throw new Error(`token exchange failed: ${res.status}`);
14  const t = await res.json();
15
16  await browser.storage.local.set({
17    refreshToken: t.refresh_token,
18    expiresAt: Date.now() + t.expires_in * 1000,
19  });
20  await browser.storage.session.set({ accessToken: t.access_token });
21  await browser.alarms.create("token-refresh", { delayInMinutes: (t.expires_in / 60) - 5 });
22  return t;
23}

Execution context: the background script. Keeping the access token in storage.session and only the refresh token in storage.local narrows what survives a browser restart — the reasoning is in refreshing and storing access tokens securely.

launchWebAuthFlow end to endThe background script builds an authorisation URL with a PKCE challenge, the browser hosts the provider window, and the returned code is exchanged for tokens the extension stores itself.Background scriptidentity APIProviderStoragelaunchWebAuthFlow(authUrl)needs a gestureuser authenticatesredirect ?code=…&state=…resolve(redirectUrl)POST /token + code_verifieraccess + refresh tokensession: access, local: refresh
Nothing in this sequence is Chrome-specific — which is exactly why it is the flow to write once.

The redirect URI problem, and the hosted bounce page

Firefox derives getRedirectURL() from a per-install UUID: every installation of your extension has a different redirect host. Providers that accept a wildcard — https://*.extensions.allizom.org/ — make this a non-issue. Providers that demand an exact URI make it a blocker, and a surprising number of enterprise identity products are in the second group.

The standard answer is a bounce page on a domain you control. You register one exact HTTPS redirect URI with the provider; that page reads the authorisation response and forwards it to whatever extension URL asked for it.

1<!-- https://auth.example.com/ext-bounce.html -->
2<script src="bounce.js"></script>
1// bounce.js — no inline script, because your own CSP should forbid it too
2const params = new URLSearchParams(location.search);
3const target = sessionStorage.getItem("extRedirect");
4if (target && /^https:\/\/[a-z0-9-]+\.(chromiumapp\.org|extensions\.allizom\.org)\//.test(target)) {
5  location.replace(target + "?" + params.toString());
6}

Execution context: an ordinary web page on your own origin, not an extension context. The pattern check is the security-critical line: without it, the bounce page is an open redirector that will forward an authorisation code anywhere an attacker names.

The extension seeds extRedirect by navigating to the bounce origin once before starting the flow, or by passing it as a signed parameter your server validates. A signed parameter is the sturdier option because it survives a user with third-party storage partitioning enabled.

Safari has the opposite shape of problem. Its redirect is the containing app’s URL scheme, which is stable and exact — but it is not an HTTPS URI, and some providers reject custom schemes outright for confidential clients. For a public client with PKCE, most now accept them; where one does not, the same bounce page works, with the app scheme as the forwarding target.

One last portability note: launchWebAuthFlow resolves with the final redirect URL, so a flow that bounces through your page resolves with the extension URL plus the forwarded parameters. Your state check still works unchanged, and it is what makes the bounce safe end to end.

A bounce page that satisfies an exact-URI providerThe extension starts the flow with the provider's single registered redirect, the bounce page validates the extension target against an allowlist and forwards the code, and the identity API resolves.Extensionstate + PKCE challengeProviderone exact redirect URIBounce pageauth.example.comvalidate, then forwardPattern checkchromiumapp / allizom onlylocation.replacecode + state forwardedlaunchWebAuthFlow resolvesstate verified
The allowlist on the bounce page is the whole security boundary — without it the page is an open redirector.

Cross-browser variation

  • Chrome / Edge: launchWebAuthFlow works identically, so this flow is the portable choice even on Chrome. Use getAuthToken only when you specifically want the browser to manage a Google token for the profile’s signed-in account.
  • Firefox: the redirect host contains a per-install UUID, so providers that demand an exact redirect URI need either a wildcard or a hosted bounce page. Firefox’s background context is an event page — alarms and top-level listener registration still apply.
  • Safari: launchWebAuthFlow presents a system authentication sheet rather than a browser window, and the redirect scheme is the app’s bundle identifier. Safari also shares cookies with the browser, so a user already signed in at the provider may complete the flow without seeing a prompt.
  • All three: the extension is a public client. Never embed a client secret — it is readable by anyone who unzips the package, and it is a review rejection on every store.

Verification

  1. Print the redirect URL on each target and confirm it matches what the provider has registered:
1browser.identity.getRedirectURL();
2// "https://5b1c…-e0f2.extensions.allizom.org/"

Execution context: the background console of the browser under test. A mismatch is the single most common cause of the flow failing immediately — see fixing OAuth redirect URI mismatches.

  1. Complete the flow and confirm storage.session holds an access token while storage.local holds only the refresh token.
  2. Force expiry by setting expiresAt into the past and confirm the refresh alarm produces a new access token without a second prompt.
  3. Run the same build in Firefox with web-ext run and confirm no Chrome-only branch was taken.

FAQ

Can I reuse my Chrome client id?

Usually not. Providers scope a client id to a redirect URI and often to a platform, so Firefox and Safari typically need their own registration. Keep the ids in a per-target build constant rather than branching at runtime.

Does Safari really need a different redirect scheme?

Yes — Safari web extensions are packaged inside an app, and the redirect is the app’s URL scheme rather than an HTTPS host. Register it alongside the others and select it at build time.

Is interactive: false supported off Chrome?

launchWebAuthFlow accepts it, and it resolves without a window when the provider can complete the flow from an existing session. Treat a rejection as “needs a gesture” rather than as an error.

Other Core APIs & Cross-Browser Data Management Resources