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(notfirebase/auth), awaitauthStateReady()before any gated read, run all Firebase code in the background with a single writer pushing a user snapshot intochrome.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:
- The background owns the only Firebase instance. Its
onAuthStateChangedis the single writer of a plain-object user snapshot inchrome.storage.local(storage.local.user). - UI surfaces never import Firebase. They subscribe to the storage key
(one initial read plus one
storage.onChangedlistener) and render the snapshot. In React that collapses to auseSyncExternalStoreview:
// 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);
- Actions flow the other way as messages. The popup sends a typed
signInmessage; 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 outsideentrypoints/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.sessionis in-memory and cleared when the browser closes: the right home for anything token-ish you must cache. Neverstorage.sync(it replicates through the user’s Google account and has tight quotas), and neverstorage.localfor 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-extensioneverywhere; 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.
-
onAuthStateChangedis the single writer of astorage.localuser snapshot; UI reads storage, never Firebase. -
getIdTokencalls 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.