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.
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
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.
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.
Cross-browser variation
- Chrome / Edge:
launchWebAuthFlowworks identically, so this flow is the portable choice even on Chrome. UsegetAuthTokenonly 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:
launchWebAuthFlowpresents 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
- 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.
- Complete the flow and confirm
storage.sessionholds an access token whilestorage.localholds only the refresh token. - Force expiry by setting
expiresAtinto the past and confirm the refresh alarm produces a new access token without a second prompt. - Run the same build in Firefox with
web-ext runand 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.
Related
- Implementing OAuth2 with launchWebAuthFlow — the flow in depth on Chrome.
- Fixing OAuth redirect URI mismatches — the failure this port hits first.
- Signing users out and revoking tokens — tearing down a session you own entirely.
- Identity and OAuth authentication — the parent guide to extension authentication.