• Manifest V3
  • Cross-platform
  • Privacy

Chrome extension Google sign-in: chrome.identity, Manifest V3 and verifying users on your backend

Chrome extension Google sign-in with chrome.identity in Manifest V3: verify the user on your server and make the same flow work in Edge and Firefox.

Author
Extenify Team
Published
Reading time
12 min read
A worn turquoise combination padlock hanging from a rusty latch on a weathered door with peeling paint

Chrome extension Google sign-in looks like a one-line API call, and in Chrome it nearly is: chrome.identity.getAuthToken() returns an access token for the signed-in Google account. The hard parts come after. Your backend still has to prove who the user is, the same code fails in Microsoft Edge, Firefox needs a different redirect, and a mismatched extension ID breaks everything between your laptop and the Chrome Web Store build.

This guide walks through Google sign-in for a Manifest V3 extension end to end: pinning the extension ID, creating the OAuth client, calling chrome.identity from the service worker, verifying the token on a Node.js server, and a server-driven flow that also works in Edge and Firefox.

Key takeaways

  • In Chrome, chrome.identity.getAuthToken() is the simplest path. It needs the identity permission, an oauth2 block in the manifest and an OAuth client of type Chrome Extension tied to your extension ID.
  • Pin the extension ID with the manifest key field during development, or the ID on your machine will not match the OAuth client and sign-in fails.
  • getAuthToken() returns an access token, not proof of identity. Send it to your server, check that its audience is your client ID, read the stable Google user ID (sub), and issue your own session.
  • Edge and Firefox do not offer getAuthToken() for Google accounts. Use launchWebAuthFlow() there, ideally pointed at your own server so the OAuth exchange and any refresh tokens stay off the client.
  • The identity permission shows no install warning. identity.email adds "Know your email address", so only request it if you need getProfileUserInfo().

Two ways to add Google sign-in to a Chrome extension

The chrome.identity API reference offers two flows, and most extensions end up needing both.

getAuthToken() launchWebAuthFlow()
Works with Google accounts in Chrome Any OAuth 2.0 or OpenID Connect provider, including Google
Browsers Chrome Chrome, Edge, Firefox and other Chromium browsers
OAuth client Google Cloud client of type Chrome Extension Whatever the provider requires; with the server-driven flow below, a normal web client on your domain
Token handling Chrome caches and renews tokens for you You manage expiry and renewal
User experience Uses the account already signed in to Chrome Opens a provider sign-in window

If your extension only ships to Chrome and only needs Google accounts, getAuthToken() is enough. If you also publish on Edge Add-ons or Firefox Add-ons, plan for launchWebAuthFlow() from the start; we cover a design that works everywhere later in this post.

Step 1: Pin your extension ID

Google ties the OAuth client to one extension ID. A published extension's ID is fixed, but an unpacked build gets an ID derived from its folder path unless you pin it. Chrome's OAuth tutorial for extensions describes the process:

  1. Zip the extension and upload it to the Chrome Developer Dashboard without publishing.
  2. Open the Package tab and choose View public key.
  3. Copy the key between the BEGIN and END lines, remove the line breaks, and add it to manifest.json as "key".
  4. Load the unpacked extension at chrome://extensions and confirm its ID matches the Item ID in the dashboard.

As the tutorial puts it, "Preserving a single ID is essential during development." If sign-in works for one teammate and not another, compare the IDs on chrome://extensions first.

Step 2: Create the OAuth client

In the Google Cloud console, open Google Auth Platform > Clients (the older console calls it Credentials), choose Create client, and select the Chrome Extension application type. Enter the extension ID in the Item ID field and copy the generated client ID.

While you are there, configure the consent screen with your app name, support email and privacy policy URL. Request the smallest set of scopes you need. For sign-in alone, openid and email (or the userinfo.email scope) are enough. Broader scopes such as Gmail or Drive access trigger Google's own verification process and a much scarier consent screen, which costs you sign-ups.

Step 3: Configure the manifest

{
  "manifest_version": 3,
  "name": "My Extension",
  "version": "1.4.0",
  "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...",
  "background": { "service_worker": "background.js", "type": "module" },
  "permissions": ["identity", "storage"],
  "oauth2": {
    "client_id": "1234567890-abc123.apps.googleusercontent.com",
    "scopes": ["openid", "email"]
  }
}

According to Chrome's permissions list, identity has no install warning. Adding identity.email lets you call getProfileUserInfo() to read the profile's email without a token, but it adds the warning "Know your email address." Warnings at install time and on updates affect conversion, so leave it out unless you really need it.

Step 4: Sign in from the service worker

Run the sign-in in the service worker and let the popup or options page ask for it with a message. Try a silent call first, and only show the consent prompt when the user clicks a button. The API reference is explicit: with interactive: false, getAuthToken "will return failure any time a prompt would be required."

// background.js
const API = 'https://api.example.com';

async function getGoogleToken(interactive) {
  // Chrome 105+ resolves with { token, grantedScopes }.
  const { token } = await chrome.identity.getAuthToken({ interactive });
  return token;
}

async function signIn(interactive, isRetry = false) {
  let token;
  try {
    token = await getGoogleToken(interactive);
  } catch (error) {
    // No cached grant and no prompt allowed, or the user closed the window.
    return { signedIn: false, reason: String(error?.message ?? error) };
  }

  const res = await fetch(`${API}/auth/google`, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ accessToken: token }),
  });

  if (res.status === 401) {
    // Chrome handed us a token Google no longer accepts. Drop it and try once more.
    await chrome.identity.removeCachedAuthToken({ token });
    if (!isRetry) return signIn(interactive, true);
    return { signedIn: false, reason: 'token_rejected' };
  }
  if (!res.ok) return { signedIn: false, reason: `server_${res.status}` };

  const { session, user } = await res.json();
  await chrome.storage.local.set({ session, user });
  return { signedIn: true, user };
}

chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
  if (message?.type === 'sign-in') {
    signIn(Boolean(message.interactive)).then(sendResponse);
    return true; // keep the channel open for the async response
  }
});

From the popup, call chrome.runtime.sendMessage({ type: 'sign-in', interactive: true }) inside a click handler. On startup, send interactive: false to restore a session quietly when the user has already granted access.

When you call getAuthToken() without an account, Chrome uses "the Sync account if there is one, or otherwise the first Google web account." If a user is signed in to Chrome with a work account but wants to use a personal one, they will need to switch Chrome profiles, or you need the launchWebAuthFlow() path, which lets them pick any account.

To sign out, revoke the session on your server, remove it from chrome.storage, and call chrome.identity.removeCachedAuthToken() with the current token. clearAllCachedAuthTokens() goes further and de-authorizes the user from all auth flows in the extension.

Step 5: Verify the user on your backend

Never trust a user ID or email that the extension sends on its own. Anyone can open the extension's code, change it, and send any value they like. Send the Google token instead and let your server check it with Google.

An access token from getAuthToken() is not a signed identity document, so the server has two jobs: confirm the token was issued to your client ID, and read the user's stable Google ID. Google's backend authentication guide recommends the sub value as the user key because "email is not a good choice because it can be changed by the user."

// server/auth-google.mjs (Node.js 20+)
const CLIENT_ID = process.env.GOOGLE_EXTENSION_CLIENT_ID;

export async function verifyGoogleAccessToken(accessToken) {
  // 1. Who was this token issued to?
  const infoRes = await fetch(
    `https://oauth2.googleapis.com/tokeninfo?access_token=${encodeURIComponent(accessToken)}`,
  );
  if (!infoRes.ok) return null; // expired, revoked or not a Google token
  const info = await infoRes.json();
  if (info.aud !== CLIENT_ID && info.azp !== CLIENT_ID) return null; // issued to another app

  // 2. Who is the user? The OpenID Connect userinfo endpoint returns sub and email.
  const userRes = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
    headers: { authorization: `Bearer ${accessToken}` },
  });
  if (!userRes.ok) return null;
  const profile = await userRes.json();

  return { googleId: profile.sub, email: profile.email, emailVerified: profile.email_verified };
}

The audience check is the important line. Without it, a token that some other app obtained for the same user would be accepted by your API, which is exactly the attack Google's guide warns about for ID tokens.

Then create your own session: store the user by googleId, generate a random session token, and return it to the extension. Call Google once per sign-in, not on every API request. Google describes the tokeninfo endpoint as a debugging aid for ID tokens and says it is "not suitable for use in production code as requests may be throttled," so keep it off your hot path. With your own session in place, every later request carries your token, and you can attach per-user limits to it. That matters for AI features in particular, where one user can drain a shared provider quota; our guide to LLM API rate limits shows a per-user quota built on exactly this session.

If you would rather verify a signed ID token, use the server-driven flow below and the google-auth-library package, whose verifyIdToken "verifies the JWT signature, the aud claim, the exp claim, and the iss claim."

Make it work in Edge and Firefox with launchWebAuthFlow

Edge and Firefox users are where many extensions discover their sign-in only works in Chrome. Microsoft's list of supported extension APIs marks identity.getAuthToken as not supported and points to launchWebAuthFlow as the alternative. MDN's identity API page lists only getRedirectURL() and launchWebAuthFlow() for Firefox. And the naive launchWebAuthFlow() setup has its own catch: one developer on the Microsoft Tech Community describes being "forced to reconnect every hour to retrieve a new access token," because nothing renews the short-lived token the way getAuthToken() does in Chrome.

You can avoid both the short-lived token and the redirect URL headaches by running the OAuth flow on your own server and using launchWebAuthFlow() only as the window that hosts it:

  1. The extension calls launchWebAuthFlow() with a URL on your server, such as https://api.example.com/auth/google/start, passing its own redirect URL from getRedirectURL() as a parameter.
  2. Your server checks that redirect URL against an allowlist of your extension IDs, then starts a normal Google web server flow with a client secret, a state value and a redirect back to your own domain.
  3. Google redirects to your server's callback. The server exchanges the code, verifies the ID token with verifyIdToken, stores any refresh token server-side, and creates a one-time code that expires in a minute.
  4. The server redirects to the extension's redirect URL with that one-time code. launchWebAuthFlow() resolves with the final URL.
  5. The extension posts the one-time code to your API and receives its session, exactly as in Step 4.
// Works in Chrome, Edge and Firefox. Firefox also exposes these calls as browser.identity.
async function signInWithServerFlow() {
  const redirectUrl = chrome.identity.getRedirectURL('google');
  const start = new URL('https://api.example.com/auth/google/start');
  start.searchParams.set('redirect', redirectUrl);

  const finalUrl = await chrome.identity.launchWebAuthFlow({
    url: start.toString(),
    interactive: true,
  });

  const code = new URL(finalUrl).searchParams.get('code');
  const res = await fetch('https://api.example.com/auth/exchange', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ code }),
  });
  if (!res.ok) throw new Error(`exchange failed: ${res.status}`);
  const { session, user } = await res.json();
  await chrome.storage.local.set({ session, user });
  return user;
}

In Chrome, the redirect URL has the form https://<extension-id>.chromiumapp.org/. Firefox derives its redirect URL from the add-on ID, so MDN advises setting the ID explicitly with browser_specific_settings; otherwise "each time you temporarily install the extension, you'll get a different redirect URL." Because Google only ever redirects to your own verified domain in this design, you never have to register the browser-specific redirect URLs with Google. MDN notes why that matters: "some OAuth servers (such as Google) only accept domains with a verified ownership as the redirect URL."

The design also keeps the one-time code, not a session or access token, in the URL, so browser history and logs never contain a reusable credential.

Common Chrome extension Google sign-in errors and fixes

  • Sign-in fails on unpacked builds but works for the store version. The local extension ID does not match the OAuth client's Item ID. Add the key field from Step 1.
  • The silent call fails on first run. Expected. With interactive: false, the call fails whenever consent is needed. Show a sign-in button and call it again with interactive: true from a click.
  • The API rejects a token that worked yesterday. Remove it with removeCachedAuthToken() and request a new one, as in the Step 4 code.
  • "Authorization page could not be loaded" with launchWebAuthFlow(). Developers on the Auth0 community forum traced this to a misspelled redirect URL and to the provider returning an error page. Log the start URL, and check that every redirect in the chain matches what you registered.
  • launchWebAuthFlow() rejects in Firefox. MDN lists the usual causes: a client ID or redirect URL that does not match the registration, a user who did not authorize, or a non-interactive call that needed interaction.
  • getAuthToken is undefined or fails in Edge. Edge and Firefox do not implement it, or the identity permission is missing. Feature-detect and fall back to the server-driven flow.

Measure the sign-in step

Sign-in is a funnel step, and a bad one leaks users quietly. Track three numbers: installs, users who reach the sign-in screen, and users who finish it. A gap between the last two usually means a confusing consent screen or a broken browser. Keep the events minimal and send them through your server, as described in our guide to Google Analytics 4 in a Manifest V3 extension, and declare the collection properly on Firefox, as covered in Firefox data collection consent for extensions.

Then compare the funnel with what the stores show. If sign-in completions drop the week a new version ships, check weekly users and new reviews on every store at once. Extenify is an extension analytics dashboard that tracks installs, weekly users, ratings, reviews and versions for your extension and competitors across the Chrome Web Store, Edge Add-ons and Firefox Add-ons, with daily change alerts. Tracking one extension is free, and the pricing page lists what the paid plans add. Our guide on tracking one extension across Chrome, Edge and Firefox explains how to line those numbers up, and measuring extension retention and uninstall rate shows what to do once users are signed in.

Frequently asked questions

Does chrome.identity.getAuthToken work in Microsoft Edge?

No. Microsoft lists identity.getAuthToken among the APIs Edge does not support and recommends launchWebAuthFlow instead. The server-driven flow in this guide handles Chrome, Edge and Firefox with one code path.

Does getAuthToken return an ID token?

No. It returns an OAuth access token, plus the granted scopes in Chrome 105 and later. To identify the user, send the access token to your server, check its audience and read the sub value, or use a flow that returns an ID token and verify that with verifyIdToken.

Does the identity permission show a warning when users install my extension?

No. Chrome lists no warning for identity. The separate identity.email permission shows "Know your email address," so add it only if you need getProfileUserInfo().

Do users need to be signed in to Chrome?

For getAuthToken(), Chrome uses the Sync account or, if there is none, the first Google account signed in on the web. With interactive: true, Chrome prompts when needed. The launchWebAuthFlow() path does not depend on the Chrome profile at all.

How do I sign in users with GitHub or Microsoft accounts?

Use launchWebAuthFlow() with that provider, or with your own server as in the server-driven flow. getAuthToken() only covers Google accounts.

Where should I store the session in the extension?

In chrome.storage.local, which web pages cannot read. Your own content scripts can, so never pass the session into the page's context. Keep the session short-lived, refresh it from your server, and never store a client secret or provider refresh token in the extension.

Summary

Chrome extension Google sign-in has three parts: get a token in the browser, prove who the user is on your server, and make the flow work in every browser you ship to. In Chrome, pin the extension ID, create a Chrome Extension OAuth client and call getAuthToken() from the service worker. On the server, check the token's audience, key the user on sub and issue your own session. For Edge and Firefox, run the OAuth flow on your server and use launchWebAuthFlow() as its window. Then measure the sign-in step like any other part of your funnel.

Image credits

Put every store in one dashboard tonight.

Sign in with Google, paste one store link, and your unified view is live in under a minute. 7 days free from your first extension, no card required, and you never touch your extension's code.

Start free