Skip to content
extension/start

Google Sign-In in a Chrome Extension: The 3 Approaches That Actually Work in MV3

The three Google login flows that work in a Manifest V3 Chrome extension: getAuthToken, launchWebAuthFlow, and offscreen documents.

TL;DR: Three Google sign-in approaches work inside a Manifest V3 extension (the Firebase web sign-in flows are not among them): chrome.identity.getAuthToken (Chrome-only fast path), chrome.identity.launchWebAuthFlow with the implicit OAuth flow (the portable default: works on Chrome and Firefox, no client secret), and an offscreen document running signInWithPopup on a page you host (Chromium-only fallback). Run everything in the background service worker, and never ship an OAuth client secret.

If you’ve tried to add “Sign in with Google” to a Chrome extension the way you would on a website, you’ve already met the wall: the popup opens and closes, the redirect never comes back, or the console fills with CSP violations. None of that is a bug in your code. It’s Manifest V3 working as designed, and the fix is to use one of the three flows that were designed for extensions.

Why the web patterns fail in MV3

Two constraints kill the standard Firebase flows:

  1. No remotely hosted code. MV3 disallows executing remote code inside the extension. The Firebase web sign-in flows load Google’s sign-in helper from the network at runtime, which the extension CSP blocks. Firebase’s own documentation for Chrome extension auth confirms the popup/redirect methods can’t run in MV3 extension pages.
  2. chrome-extension:// is not a redirect target. OAuth redirect flows end by navigating back to your origin. Google’s authorization server won’t redirect to a chrome-extension:// URL, and your MV3 background isn’t a page at all; it’s a service worker with no DOM.

So every working approach does the same dance a different way: obtain a Google OAuth credential through a browser-provided mechanism, then hand it to Firebase with signInWithCredential in the background.

Approach 1: chrome.identity.getAuthToken, the Chrome fast path

getAuthToken asks Chrome itself for an OAuth access token for the browser’s signed-in Google account. One consent prompt, no popup window, instant on repeat calls.

chrome.identity.getAuthToken({ interactive: true }, (result) => {
  if (chrome.runtime.lastError || !result?.token) {
    // fall back to launchWebAuthFlow
    return;
  }
  const credential = GoogleAuthProvider.credential(null, result.token);
  // signInWithCredential(auth, credential) in the background
});

Constraints that make it a fast path, not the answer:

  • Chrome only. Firefox doesn’t have it, and Chromium forks that strip Google account integration (or users without a browser Google profile) fail it too.
  • It requires the oauth2 key in your manifest with a client ID of type “Chrome Extension”.
  • It returns an access token only: fine for GoogleAuthProvider.credential(null, accessToken), but you never see an ID token.

In ExtensionStart’s packages/core-auth, getAuthToken is tried first and any failure is swallowed silently: a missing oauth2 manifest key, a non-Chrome browser, or no browser account all just fall through to approach 2:

// packages/core-auth/src/google.ts
function tryGetAuthToken(): Promise<string | null> {
  return new Promise((resolve) => {
    try {
      chrome.identity.getAuthToken({ interactive: true }, (result) => {
        // Missing oauth2 manifest key / non-Chrome / no browser account:
        // swallow and fall back to launchWebAuthFlow.
        if (chrome.runtime.lastError || !result?.token) resolve(null);
        else resolve(result.token);
      });
    } catch {
      resolve(null);
    }
  });
}

Approach 2: launchWebAuthFlow + implicit flow, the portable default

chrome.identity.launchWebAuthFlow opens Google’s real authorization page in a browser-controlled window and resolves with the final redirect URL. The trick is the redirect target: a per-extension virtual URL (https://<extension-id>.chromiumapp.org/ on Chromium) that the browser intercepts, so no server of yours is involved.

With response_type=token id_token, Google returns the tokens directly in the URL fragment (the implicit flow), which means no client secret exists anywhere. That matters because an extension bundle is world-readable; a “secret” shipped in it is not a secret. The kit’s implementation adds state (CSRF check) and nonce on every request:

// packages/core-auth/src/google.ts (trimmed)
const params = new URLSearchParams({
  client_id: options.clientId,
  response_type: "token id_token",
  redirect_uri: chrome.identity.getRedirectURL(),
  scope: scopes.join(" "),
  state,
  nonce,
  prompt: "select_account",
});
const redirectUrl = await launchWebAuthFlow(`${AUTH_ENDPOINT}?${params}`);
const fragment = new URLSearchParams(new URL(redirectUrl).hash.slice(1));
if (fragment.get("state") !== state) {
  throw new AuthError("invalid-credential", "OAuth state mismatch");
}
return GoogleAuthProvider.credential(fragment.get("id_token"), fragment.get("access_token"));

Setup is two steps: create a Web application OAuth client in Google Cloud with authorized redirect URI https://<extension-id>.chromiumapp.org/, and enable the Google provider in Firebase. If you prefer an authorization-code flow, core-auth also ships a PKCE variant (S256 challenge, secretless token exchange); never pair it with a client type that expects a secret.

This is the approach to ship: it works on Chrome, Edge, and, via browser.identity.launchWebAuthFlow, Firefox, where getRedirectURL() returns an extensions.allizom.org URL instead. Register both redirect URIs on the same OAuth client if you ship both browsers.

Approach 3: offscreen document + signInWithPopup, the Chromium fallback

Firebase’s documented MV3 recipe: create an offscreen document (an invisible extension page), have it embed an iframe of a page hosted on your Firebase Hosting domain, run signInWithPopup there (where remote code is allowed, because it’s a normal web page) and post the resulting credential back to the extension.

ExtensionStart keeps this as the zero-config path: if WXT_GOOGLE_OAUTH_CLIENT_ID is unset, sign-in routes through the offscreen fallback so you can develop before creating an OAuth client. It is strictly a fallback because:

  • Firefox has no offscreen API; this path is Chromium-only.
  • It needs a hosted helper page (VITE_FIREBASE_HOSTING_URL), so “zero config” still means “your Firebase project’s Hosting domain must be authorized”.
  • More moving parts: offscreen lifecycle, postMessage handshake, iframe.

The decision tree

User clicks "Sign in with Google"

├─ OAuth client ID configured?
│  ├─ YES → getAuthToken fast path (Chrome) → falls back to
│  │        launchWebAuthFlow implicit flow (Chrome + Firefox)
│  └─ NO  → offscreen signInWithPopup fallback (Chromium only, dev convenience)

└─ Targeting Firefox? → the client ID is mandatory; only launchWebAuthFlow runs there.

Whatever path produced the credential, finish in the background service worker with signInWithCredential, and keep the resulting ID tokens there (see the companion guide on Firebase Auth in MV3).

The errors everyone hits

Symptom Cause Fix
Auth window opens and instantly closes; redirect_uri_mismatch Redirect URI doesn’t match https://<current-extension-id>.chromiumapp.org/; unpacked extension IDs change with the folder path Pin the ID with a manifest key, or update the OAuth client’s URI
getAuthToken silently does nothing Missing oauth2 manifest key, non-Chrome browser, or no browser Google account Treat it as optional; always have the launchWebAuthFlow fallback
auth/operation-not-allowed Google provider not enabled in Firebase console Authentication → Sign-in method → enable Google
auth/unauthorized-domain (offscreen path) The hosting page’s domain isn’t in Firebase’s authorized domains Add your Hosting domain in Authentication → Settings
“OAuth state mismatch” The redirect returned a different state than sent, a stale or tampered response Retry; the kit throws invalid-credential and never accepts the tokens
Works packed, breaks unpacked Different extension ID between the store build and your dev load Use the manifest key field so both share one ID

What ExtensionStart ships

All three approaches, behind one AuthStrategy interface: getAuthToken fast path → launchWebAuthFlow implicit (or PKCE) → offscreen fallback when no client ID is configured, plus email/password alongside, anonymous-first linking, and lint rules that keep ID tokens out of UI code. If you’d rather not re-derive the redirect-URI dance yourself, that’s exactly what the kit’s packages/core-auth is for.

Frequently asked questions

Why doesn't the normal Firebase web sign-in work in a Chrome extension?

A redirect flow needs to navigate away to Google and come back to your page. An extension page lives at a chrome-extension:// URL, which is not a valid OAuth redirect target, and the Firebase sign-in helper code it depends on is remotely hosted, which Manifest V3's content security policy forbids inside the extension. It fails every time, in every MV3 extension.

Do I need an OAuth client secret for Google login in a Chrome extension?

No, and you must never ship one. Extensions are public clients: anyone can unzip your bundle and read it. Use the implicit flow (token comes back in the URL fragment) or an authorization-code flow with PKCE, both of which are designed for clients that cannot keep a secret.

Why did my launchWebAuthFlow redirect URI suddenly stop matching?

The redirect URI embeds your extension ID (https://<extension-id>.chromiumapp.org/), and an unpacked extension's ID is derived from its folder path. Load the same code from a different directory and the ID, and therefore the URI, changes. Pin the ID with a "key" field in the manifest, or re-check the URI whenever sign-in starts opening and immediately closing with an error.

Does Google sign-in work in Firefox extensions?

Yes, but only via browser.identity.launchWebAuthFlow. Firefox has neither chrome.identity.getAuthToken nor the offscreen-document API, so the web-auth-flow path with an OAuth client ID is mandatory if you target Firefox.

Can I use chrome.identity.getAuthToken with Firebase Auth?

Yes. getAuthToken returns a Google OAuth access token; pass it to GoogleAuthProvider.credential(null, accessToken) and call signInWithCredential. It only works on Chrome with a signed-in browser Google account and requires the oauth2 key in your manifest, so treat it as a fast path with a fallback, not your only path.

Which Google sign-in approach should I ship in 2026?

launchWebAuthFlow with the implicit flow is the portable default: it works on Chrome, Edge, and Firefox, needs no client secret, and degrades predictably. Layer getAuthToken on top as a Chrome fast path, and keep the offscreen signInWithPopup recipe only as a zero-config development fallback.