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.launchWebAuthFlowwith the implicit OAuth flow (the portable default: works on Chrome and Firefox, no client secret), and an offscreen document runningsignInWithPopupon 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:
- 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.
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 achrome-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
oauth2key 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.