Skip to content
extension/start

Firebase Auth in Manifest V3: Service Workers, Storage, and the Traps

How to run Firebase Auth in an MV3 chrome extension: the firebase/auth/web-extension entry point, authStateReady, the single-writer storage pattern, and token hygiene.

TL;DR: Firebase Auth works fine in an MV3 extension if you follow four rules: import firebase/auth/web-extension (not firebase/auth), await authStateReady() before any gated read, run all Firebase code in the background with a single writer pushing a user snapshot into chrome.storage, and keep ID tokens background-only. Break any of them and you get phantom sign-outs, racing UI surfaces, or tokens leaking into content scripts.

Getting a credential (see Google sign-in in MV3) is half the job. The other half is keeping a Firebase session coherent inside a runtime that kills your JavaScript every 30 seconds. Here are the traps, in the order you’ll hit them.

Trap 1: the wrong import

The standard firebase/auth build assumes it’s running in a page: DOM, window, popup and iframe helpers. An MV3 background is a service worker; none of that exists. Firebase ships a dedicated entry point for exactly this:

import { getAuth, onAuthStateChanged, signInWithCredential } from "firebase/auth/web-extension";

firebase/auth/web-extension carries the core API surface (signInWithCredential, onAuthStateChanged, authStateReady, linkWithCredential) without the page-only machinery, and its persistence layer is extension-safe. Use it everywhere in the extension. (ExtensionStart pins firebase@^12; you want at least 10.4 for authStateReady and the web-extension persistence fixes.)

Trap 2: the ephemeral service worker

The MV3 background is not a long-lived page. Per Chrome’s service worker lifecycle, the worker is terminated after ~30 seconds of idle and revived on events; even busy workers get capped. Two consequences for auth:

Module globals are a cache, not state. A let currentUser at module scope resets on every wake. The persistent truth must live in chrome.storage (and, for Firebase’s own session, in IndexedDB, which does survive).

Rehydration is asynchronous. When the worker wakes and a message handler asks “is the user signed in?”, Firebase may still be reading its persisted session. Read auth.currentUser too early and you get null, a phantom signed-out state, even though the user never signed out. The fix is authStateReady(), and ExtensionStart’s strategy wraps every operation in it:

// packages/core-auth/src/strategy.ts (trimmed)
const ready = () => auth.authStateReady();

async getUser() {
  await ready();
  return auth.currentUser ? toAuthUser(auth.currentUser) : null;
},

async getToken(forceRefresh = false) {
  await ready();
  const current = auth.currentUser;
  return current ? current.getIdToken(forceRefresh) : null;
},

One related MV3 rule: listeners must be registered synchronously at the top level of the worker script. An onAuthStateChanged (or onMessage) registered after an await can miss the very event that woke the worker. Register at top level; await hydration inside the handler.

Trap 3: every surface running its own Firebase

An extension has many UI surfaces (popup, options page, side panel, content scripts), each a separate JavaScript context. If each one calls getAuth() and listens to onAuthStateChanged, you get N Firebase instances racing the same IndexedDB, N slightly different answers during rehydration, and Firebase bundled into contexts (content scripts) that run inside arbitrary web pages.

The pattern that works is single writer, storage as the bus:

  1. The background owns the only Firebase instance. Its onAuthStateChanged is the single writer of a plain-object user snapshot in chrome.storage.local (storage.local.user).
  2. UI surfaces never import Firebase. They subscribe to the storage key (one initial read plus one storage.onChanged listener) and render the snapshot. In React that collapses to a useSyncExternalStore view:
// apps/extension: the useAuth idiom (trimmed)
const authView = defineStorageView<AuthView>(
  "user",
  (raw) => ({ loading: false, user: (raw as AuthUser | undefined) ?? null }),
  { initial: { loading: true, user: null } },
);

const useAuth = () => useSyncExternalStore(authView.subscribe, authView.getSnapshot);
  1. Actions flow the other way as messages. The popup sends a typed signIn message; the background runs the flow and the storage snapshot updates; every surface re-renders at once. No surface ever waits on Firebase directly, so a cold service worker can’t make the popup flash “signed out”.

This is more than tidiness: it means auth state is consistent across surfaces by construction, survives worker restarts (storage outlives the worker), and keeps the Firebase SDK out of content scripts entirely.

Trap 4: tokens wandering out of the background

A Firebase ID token is a bearer credential for your backend. Two rules keep it contained:

  • ID tokens never leave the background. UI and content scripts don’t call getIdToken; in ExtensionStart that’s lint-enforced outside entrypoints/background/. When the popup needs a backend call, it sends a message (billingCheckout, gateFeature, …); the background attaches the token server-side of the message boundary. A content script that holds a token is a token one hostile page away from exfiltration primitives.
  • Ephemeral data goes in storage.session. chrome.storage.session is in-memory and cleared when the browser closes: the right home for anything token-ish you must cache. Never storage.sync (it replicates through the user’s Google account and has tight quotas), and never storage.local for secrets.

Round it out with logout-everywhere: on sign-out, call your backend to revoke refresh tokens (revokeRefreshTokens in the Admin SDK) before clearing local state. Sessions on other devices then die when their current ID token expires (at most an hour). Make it best-effort; local sign-out should proceed even if the network call fails.

The offscreen fallback, briefly

If you use Firebase’s offscreen-document recipe for signInWithPopup (the Chromium-only fallback when no OAuth client ID is configured), the same architecture holds: the offscreen page produces a credential and hands it to the background; the background’s signInWithCredential and single-writer snapshot do the rest. The offscreen document is a credential source, not a place where auth state lives.

The checklist

  • Import firebase/auth/web-extension everywhere; Firebase ≥ 10.4.
  • One Firebase instance, in the background only.
  • await auth.authStateReady() inside every handler that reads auth state.
  • Listeners registered synchronously at the worker’s top level.
  • onAuthStateChanged is the single writer of a storage.local user snapshot; UI reads storage, never Firebase.
  • getIdToken calls exist only in the background; backend calls from UI go through messages.
  • Ephemeral/token-ish data in storage.session; refresh-token revocation on sign-out.

ExtensionStart ships this whole shape pre-wired: the strategy, the storage views, the lint rules that make the token rule unbreakable, and e2e tests that restart the service worker on purpose. If you’re building it yourself, build it in this order; if you’d rather not, it’s already built.

Frequently asked questions

Why does Firebase Auth report signed-out right after my service worker restarts?

After a wake, Firebase needs a moment to rehydrate its persisted session from IndexedDB. Any check that reads auth.currentUser before that finishes sees a phantom signed-out state. Await auth.authStateReady() (Firebase 10.4+) before every gated read and the phantom disappears.

What is firebase/auth/web-extension and when should I import it?

It's the Firebase Auth build for browser extensions. The standard firebase/auth entry point assumes DOM and page APIs that an MV3 service worker doesn't have. Import firebase/auth/web-extension everywhere in the extension; it ships the same core APIs (signInWithCredential, onAuthStateChanged, authStateReady) without the page-only machinery.

Can I call Firebase Auth directly from my extension popup?

You can, but you shouldn't. Each surface would spin up its own Firebase instance, race the service worker's rehydration, and briefly disagree about auth state. The reliable pattern is one Firebase instance in the background that writes a user snapshot to chrome.storage; the popup just reads storage.

Where should I store the Firebase ID token in a chrome extension?

Ideally nowhere: call getIdToken() in the background when you make a backend request and let Firebase manage refresh. If you must cache token-adjacent data, use chrome.storage.session, which is memory-only and cleared when the browser closes. Never put tokens in storage.local or storage.sync, and never hand them to UI or content scripts.

How long does an MV3 background service worker actually live?

Chrome typically terminates it after about 30 seconds of inactivity, and even an active worker is capped (long tasks around 5 minutes). Events wake it again. Design every auth read to assume the worker just cold-started: persist to chrome.storage and await rehydration inside handlers.

Does Firebase Auth persistence survive service worker restarts?

Yes; the session persists in IndexedDB and survives worker restarts and browser restarts. What doesn't survive is your JavaScript state: module globals reset on every wake, which is exactly why the storage-snapshot pattern exists.