Repoker
← All posts

July 22, 2026

Porting a Chrome extension to Firefox without forking the code

I shipped Repoker's extension to the Chrome Web Store first, told myself Firefox could wait, and then a tester asked the obvious question: "Is this coming to Firefox?" So I sat down expecting an afternoon of small annoyances. Porting a Chrome extension to Firefox has a reputation for being fiddly, and the reputation is half-earned. Most of it really is the same code. The other half is a short list of differences that will each cost you an hour if you don't know they're coming.

Both browsers now speak Manifest V3, which is the thing that makes this story short. Firefox landed MV3 support in version 121, so the days of maintaining a separate MV2 build for Firefox are behind us. One manifest version, one set of APIs, mostly one codebase. But "mostly" is doing some work in that sentence, and this post is about the gap.

One codebase, two manifests

The first decision was whether to keep two copies of the extension or build both from one source. Two copies is how you end up with a bug fixed in Chrome and still live in Firefox three weeks later. So the extension is a single codebase, and a tiny build step stamps out the browser-specific bits.

The only real difference between the two manifests is how the background script is declared. Chrome MV3 wants a service worker. Firefox MV3 wants a background scripts array. And Firefox needs one extra block that Chrome doesn't understand. The build script merges a shared manifest.base.json with whichever target you're building:

if (target === "firefox") {
  manifest.background = { scripts: ["background.js"] };
  manifest.browser_specific_settings = {
    gecko: {
      id: "repoker@repoker.app",
      strict_min_version: "121.0",
      data_collection_permissions: { required: ["authenticationInfo"] },
    },
  };
} else {
  manifest.background = { service_worker: "background.js" };
}

That gecko block is worth pausing on. The id is a stable add-on identifier Firefox insists on for a signed extension, and strict_min_version is where the "Firefox got MV3 in 121" fact turns into an actual line of config. The data_collection_permissions field is newer: AMO, Mozilla's add-on store, now asks you to declare up front what data your extension collects. Repoker sends the user's GitHub auth token to its own server to authenticate the session, and it collects no analytics or telemetry, so the honest declaration is a single required permission, authenticationInfo. Everything else in the manifest, the icons, the content scripts, the host permissions, is identical across both browsers.

The chrome global that isn't there

Here's the difference that touches the most code, and thankfully it's a one-liner to solve. Chrome and Edge expose the extension APIs on a global called chrome. Firefox exposes the same surface as browser. If your code is littered with chrome.storage.local.get(...), none of it resolves in Firefox.

You could sprinkle if (typeof browser !== "undefined") everywhere. Don't. Alias it once and use the alias everywhere:

export const ext: typeof chrome =
  globalThis.browser ?? chrome;

There's a subtlety that makes this cleaner than it used to be. The old chrome.* APIs were callback-based, and browser.* promisified. That mismatch used to force a polyfill. But every API Repoker touches, storage, runtime, identity, returns a promise on both browsers under MV3, so the alias is genuinely all you need. Import ext, call ext.storage, ext.identity, ext.runtime, and forget which browser you're in.

The redirect that worked everywhere except Firefox

Now the first bug that only Firefox could produce. Sign-in worked perfectly in Chrome, and in Firefox it died with a blunt invalid redirect_uri.

Repoker authenticates the extension with identity.launchWebAuthFlow. The extension asks the browser for its callback URL, sends the user to Repoker to sign in, and the server redirects a freshly minted token back to that callback:

const redirectUri = ext.identity.getRedirectURL();
const url = `${REPOKER_ORIGIN}/api/extension/connect?redirect_uri=${encodeURIComponent(redirectUri)}`;
const finalUrl = await ext.identity.launchWebAuthFlow({ url, interactive: true });

The catch is what getRedirectURL() actually returns, because it differs by browser. On Chromium it hands back https://<id>.chromiumapp.org/. On Firefox it's https://<id>.extensions.allizom.org/. Two different vendor-controlled hosts for the exact same job.

My server, quite reasonably, refused to redirect a token to any host it didn't recognize. You never want to bounce a freshly minted credential to an arbitrary URL, so the endpoint validates the redirect target against an allowlist. The bug was that the allowlist only knew about Chrome:

function isExtensionRedirect(uri) {
  const u = new URL(uri);
  return u.protocol === "https:" &&
    (u.hostname.endsWith(".chromiumapp.org") ||
     u.hostname.endsWith(".extensions.allizom.org"));
}

Adding the Firefox host to the check fixed it. The lesson I took away: any time a browser hands you a magic URL, assume the other browser's magic URL looks different. The security posture stays the same, you're still only ever redirecting to a vendor host you trust, but "the vendor host" is now two hosts.

The bug that wasn't about Firefox at all

The second Firefox-only failure was sneakier, because the fix belonged somewhere else entirely.

With auth working, I opened a room in Firefox and it hung on "Linking this issue to the room." The console showed a Content Security Policy violation: the page had blocked a connection to wss://repoker.app. The socket that carries every vote was refusing to open.

The reason is that a content script runs in the page's world, so it inherits the page's CSP. GitHub ships a strict connect-src, and Firefox enforces it against content scripts where Chrome had been letting it slide. So the very code that worked in Chrome was never actually allowed to work, and only Firefox was strict enough to say so.

The fix was to move the WebSocket out of the content script and into the background, which isn't subject to any page's CSP. That turned out to be the right move for a completely separate reason involving Manifest V3's service worker lifecycle, which I wrote about in making a WebSocket survive Manifest V3. Two different problems, one architecture change that solved both. Have you noticed how often that happens? The constraint you resent ends up pushing you toward the design you should have picked anyway.

Getting through AMO review

One last difference that has nothing to do with code and everything to do with shipping. Mozilla reviews add-on source. Because the extension is bundled with esbuild, the code AMO sees in the package isn't the code I wrote, so the submission has to include the source and instructions to reproduce the build.

That pushed me to make the extension genuinely self-buildable, which is a good discipline regardless. From the source archive, a reviewer runs npm install and one build command and gets back the exact dist/ that was uploaded. The one wrinkle worth flagging: the extension imports a shared types file from outside its own folder, so the source archive has to preserve that relative path or the build won't resolve it. I found that out by extracting the archive into a clean directory and building it myself, which is exactly what a reviewer does, and exactly the check you want to run before you submit rather than after they bounce it.

What the port actually cost

Add it all up and porting a Chrome extension to Firefox came to this: one build flag, a manifest block, a one-line API alias, one allowlist entry, and a socket that moved to where it should have lived from the start. The genuinely Firefox-specific work was an afternoon, most of it the two bugs above. The self-buildable packaging for AMO took longer than the code, and it was worth it.

If you're weighing whether to bother, I'd say do it. A second store listing is real distribution, the shared-codebase tax is close to zero once these differences are handled, and every Firefox user who installs it is someone Chrome was never going to reach. Repoker's extension is live on both the Chrome Web Store and Firefox Add-ons now. Same code, two homes.

Repoker is an independent tool and is not affiliated with, endorsed by, or sponsored by GitHub, Inc. “GitHub” and the GitHub logo are trademarks of GitHub, Inc.