Chrome Extension Oauth Identity — Best Practices

26 min read

OAuth and Identity Patterns

Overview

Chrome extensions that need user authentication face unique challenges: the extension popup disappears when it loses focus, service workers terminate between requests, and token storage must survive browser restarts. Chrome provides chrome.identity with two authentication flows — getAuthToken for Google accounts and launchWebAuthFlow for everything else. This guide covers eight practical patterns for building a complete, type-safe authentication layer in a Manifest V3 extension.


Auth Flow Comparison

Feature getAuthToken launchWebAuthFlow
Provider Google only Any OAuth 2.0 / OIDC provider
User experience Silent or one-click consent Opens a new browser window
Token management Chrome handles refresh You handle refresh manually
Scopes Google API scopes Provider-specific scopes
Manifest key oauth2.client_id + oauth2.scopes permissions: ["identity"]
Offline access Built-in via getAuthToken cache Requires refresh token storage

Pattern 1: Google OAuth with chrome.identity.getAuthToken

The simplest auth flow — Chrome manages the token lifecycle for Google accounts:

// manifest.json (partial)
{
  "permissions": ["identity"],
  "oauth2": {
    "client_id": "YOUR_CLIENT_ID.apps.googleusercontent.com",
    "scopes": [
      "https://www.googleapis.com/auth/userinfo.email",
      "https://www.googleapis.com/auth/drive.readonly"
    ]
  }
}
// lib/google-auth.ts

interface GoogleUserInfo {
  id: string;
  email: string;
  name: string;
  picture: string;
}

export async function getGoogleToken(
  interactive: boolean = true
): Promise<string> {
  const result = await chrome.identity.getAuthToken({ interactive });
  if (!result.token) {
    throw new Error("No token returned");
  }
  return result.token;
}

export async function getGoogleUserInfo(): Promise<GoogleUserInfo> {
  const token = await getGoogleToken();
  const response = await fetch(
    "https://www.googleapis.com/oauth2/v2/userinfo",
    { headers: { Authorization: `Bearer ${token}` } }
  );

  if (!response.ok) {
    // Token may be stale — remove and retry once
    if (response.status === 401) {
      await removeCachedToken(token);
      const freshToken = await getGoogleToken();
      const retry = await fetch(
        "https://www.googleapis.com/oauth2/v2/userinfo",
        { headers: { Authorization: `Bearer ${freshToken}` } }
      );
      if (!retry.ok) throw new Error(`Google API error: ${retry.status}`);
      return retry.json();
    }
    throw new Error(`Google API error: ${response.status}`);
  }
  return response.json();
}

async function removeCachedToken(token: string): Promise<void> {
  await chrome.identity.removeCachedAuthToken({ token });
}

Gotcha: Token Caching

getAuthToken returns a cached token on subsequent calls. If the token is revoked server-side, your API calls will fail with 401. Always call removeCachedAuthToken before retrying.


Pattern 2: Non-Google Providers with launchWebAuthFlow

For GitHub, Twitter, or any other OAuth 2.0 provider, use launchWebAuthFlow:

// lib/oauth-providers.ts

interface OAuthConfig {
  authorizeUrl: string;
  tokenUrl: string;
  clientId: string;
  clientSecret?: string; // Some flows require this
  scopes: string[];
}

const GITHUB_CONFIG: OAuthConfig = {
  authorizeUrl: "https://github.com/login/oauth/authorize",
  tokenUrl: "https://github.com/login/oauth/access_token",
  clientId: "YOUR_GITHUB_CLIENT_ID",
  clientSecret: "YOUR_GITHUB_CLIENT_SECRET",
  scopes: ["read:user", "repo"],
};

export async function launchOAuthFlow(
  config: OAuthConfig
): Promise<string> {
  const redirectUrl = chrome.identity.getRedirectURL();
  const state = crypto.randomUUID();

  const authUrl = new URL(config.authorizeUrl);
  authUrl.searchParams.set("client_id", config.clientId);
  authUrl.searchParams.set("redirect_uri", redirectUrl);
  authUrl.searchParams.set("scope", config.scopes.join(" "));
  authUrl.searchParams.set("state", state);
  authUrl.searchParams.set("response_type", "code");

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

  if (!responseUrl) {
    throw new Error("No callback URL returned");
  }

  const params = new URL(responseUrl).searchParams;
  if (params.get("state") !== state) {
    throw new Error("OAuth state mismatch — possible CSRF attack");
  }

  const code = params.get("code");
  if (!code) throw new Error("No authorization code in callback");

  // Exchange the code for a token
  return exchangeCodeForToken(config, code, redirectUrl);
}

async function exchangeCodeForToken(
  config: OAuthConfig,
  code: string,
  redirectUri: string
): Promise<string> {
  const response = await fetch(config.tokenUrl, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Accept: "application/json",
    },
    body: JSON.stringify({
      client_id: config.clientId,
      client_secret: config.clientSecret,
      code,
      redirect_uri: redirectUri,
    }),
  });

  if (!response.ok) throw new Error(`Token exchange failed: ${response.status}`);
  const data = await response.json();
  return data.access_token;
}

Gotcha: Redirect URL Format

chrome.identity.getRedirectURL() returns https://<extension-id>.chromiumapp.org/. Register this exact URL with your OAuth provider. During development, the extension ID changes if unpacked — pin it with the key field in manifest.json.


Pattern 3: Token Storage and Refresh Cycle

Store tokens securely and manage refresh cycles without leaking credentials:

// lib/token-store.ts

interface StoredTokens {
  accessToken: string;
  refreshToken?: string;
  expiresAt: number; // Unix timestamp in ms
  provider: string;
}

const TOKEN_KEY = "auth_tokens";

export async function storeTokens(tokens: StoredTokens): Promise<void> {
  // Use session storage for access tokens (cleared on browser restart)
  await chrome.storage.session.set({
    [TOKEN_KEY]: tokens,
  });

  // Persist refresh token in local storage (survives restarts)
  if (tokens.refreshToken) {
    await chrome.storage.local.set({
      [`${TOKEN_KEY}_refresh`]: {
        refreshToken: tokens.refreshToken,
        provider: tokens.provider,
      },
    });
  }
}

export async function getAccessToken(): Promise<string | null> {
  const result = await chrome.storage.session.get(TOKEN_KEY);
  const tokens: StoredTokens | undefined = result[TOKEN_KEY];

  if (!tokens) return null;

  // Refresh 60 seconds before expiry
  if (Date.now() > tokens.expiresAt - 60_000) {
    return refreshAccessToken(tokens.provider);
  }

  return tokens.accessToken;
}

async function refreshAccessToken(
  provider: string
): Promise<string | null> {
  const result = await chrome.storage.local.get(`${TOKEN_KEY}_refresh`);
  const stored = result[`${TOKEN_KEY}_refresh`];

  if (!stored?.refreshToken) return null;

  try {
    const response = await fetch(getTokenUrl(provider), {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        grant_type: "refresh_token",
        refresh_token: stored.refreshToken,
        client_id: getClientId(provider),
      }),
    });

    if (!response.ok) {
      // Refresh token is revoked — clear everything
      await clearTokens();
      return null;
    }

    const data = await response.json();
    await storeTokens({
      accessToken: data.access_token,
      refreshToken: data.refresh_token ?? stored.refreshToken,
      expiresAt: Date.now() + data.expires_in * 1000,
      provider,
    });

    return data.access_token;
  } catch {
    return null;
  }
}

export async function clearTokens(): Promise<void> {
  await Promise.all([
    chrome.storage.session.remove(TOKEN_KEY),
    chrome.storage.local.remove(`${TOKEN_KEY}_refresh`),
  ]);
}

Why Split Storage?

Access tokens go in chrome.storage.session so they vanish when the browser closes — reducing the window of exposure. Refresh tokens go in chrome.storage.local so the user doesn’t have to re-authenticate after every restart.


Pattern 4: Typed Auth State Machine

Model authentication as an explicit state machine to eliminate impossible states:

// lib/auth-state.ts

type AuthState =
  | { status: "signed-out" }
  | { status: "signing-in"; provider: string }
  | { status: "signed-in"; user: AuthUser; provider: string }
  | { status: "error"; error: string; provider?: string };

interface AuthUser {
  id: string;
  email: string;
  displayName: string;
  avatarUrl?: string;
}

type AuthEvent =
  | { type: "START_SIGN_IN"; provider: string }
  | { type: "SIGN_IN_SUCCESS"; user: AuthUser; provider: string }
  | { type: "SIGN_IN_ERROR"; error: string; provider?: string }
  | { type: "SIGN_OUT" };

function authReducer(state: AuthState, event: AuthEvent): AuthState {
  switch (event.type) {
    case "START_SIGN_IN":
      if (state.status === "signing-in") return state; // Prevent double sign-in
      return { status: "signing-in", provider: event.provider };

    case "SIGN_IN_SUCCESS":
      return {
        status: "signed-in",
        user: event.user,
        provider: event.provider,
      };

    case "SIGN_IN_ERROR":
      return {
        status: "error",
        error: event.error,
        provider: event.provider,
      };

    case "SIGN_OUT":
      return { status: "signed-out" };
  }
}

// Persist and broadcast state changes
export class AuthStateMachine {
  private state: AuthState = { status: "signed-out" };
  private listeners = new Set<(state: AuthState) => void>();

  async initialize(): Promise<void> {
    const result = await chrome.storage.local.get("authState");
    if (result.authState) {
      this.state = result.authState;
      this.notify();
    }
  }

  dispatch(event: AuthEvent): void {
    this.state = authReducer(this.state, event);
    this.notify();
    chrome.storage.local.set({ authState: this.state });

    // Broadcast to other contexts (popup, options, content scripts)
    chrome.runtime.sendMessage({
      type: "AUTH_STATE_CHANGED",
      state: this.state,
    }).catch(() => {
      // No listeners — expected when popup is closed
    });
  }

  getState(): AuthState {
    return this.state;
  }

  subscribe(listener: (state: AuthState) => void): () => void {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  }

  private notify(): void {
    for (const listener of this.listeners) {
      listener(this.state);
    }
  }
}

The state machine guarantees that the UI never shows a stale state. If sign-in fails, the UI shows the error state — not a loading spinner stuck forever.


Pattern 5: Multi-Account Support

Some extensions need to manage multiple authenticated accounts simultaneously:

// lib/multi-account.ts

interface Account {
  id: string;
  provider: string;
  email: string;
  displayName: string;
  avatarUrl?: string;
  isActive: boolean;
}

interface AccountStore {
  accounts: Account[];
  activeAccountId: string | null;
}

const ACCOUNTS_KEY = "accountStore";

export class MultiAccountManager {
  private store: AccountStore = { accounts: [], activeAccountId: null };

  async initialize(): Promise<void> {
    const result = await chrome.storage.local.get(ACCOUNTS_KEY);
    if (result[ACCOUNTS_KEY]) {
      this.store = result[ACCOUNTS_KEY];
    }
  }

  async addAccount(account: Omit<Account, "isActive">): Promise<void> {
    const existing = this.store.accounts.findIndex(
      (a) => a.id === account.id && a.provider === account.provider
    );

    if (existing >= 0) {
      // Update existing account info
      this.store.accounts[existing] = {
        ...account,
        isActive: this.store.activeAccountId === account.id,
      };
    } else {
      this.store.accounts.push({ ...account, isActive: false });
    }

    // If this is the first account, activate it
    if (this.store.accounts.length === 1) {
      await this.switchAccount(account.id);
      return;
    }

    await this.persist();
  }

  async switchAccount(accountId: string): Promise<void> {
    this.store.accounts = this.store.accounts.map((a) => ({
      ...a,
      isActive: a.id === accountId,
    }));
    this.store.activeAccountId = accountId;
    await this.persist();

    // Notify all contexts of account switch
    chrome.runtime.sendMessage({
      type: "ACCOUNT_SWITCHED",
      accountId,
    }).catch(() => {});
  }

  async removeAccount(accountId: string): Promise<void> {
    this.store.accounts = this.store.accounts.filter(
      (a) => a.id !== accountId
    );

    // Clear tokens for this account
    await chrome.storage.session.remove(`tokens_${accountId}`);
    await chrome.storage.local.remove(`refresh_${accountId}`);

    // If the active account was removed, switch to the first remaining
    if (this.store.activeAccountId === accountId) {
      const next = this.store.accounts[0];
      this.store.activeAccountId = next?.id ?? null;
      if (next) next.isActive = true;
    }

    await this.persist();
  }

  getActiveAccount(): Account | null {
    return (
      this.store.accounts.find(
        (a) => a.id === this.store.activeAccountId
      ) ?? null
    );
  }

  getAllAccounts(): Account[] {
    return [...this.store.accounts];
  }

  private async persist(): Promise<void> {
    await chrome.storage.local.set({ [ACCOUNTS_KEY]: this.store });
  }
}

Pattern 6: Silent Token Refresh in Service Workers

Service workers terminate after ~30 seconds of inactivity. Use alarms to keep tokens fresh:

// background.ts

import { getAccessToken, storeTokens } from "./lib/token-store";

const REFRESH_ALARM = "token-refresh";

// Schedule periodic refresh when the extension starts
chrome.runtime.onInstalled.addListener(() => {
  chrome.alarms.create(REFRESH_ALARM, {
    periodInMinutes: 45, // Refresh well before the typical 60-min expiry
  });
});

chrome.alarms.onAlarm.addListener(async (alarm) => {
  if (alarm.name !== REFRESH_ALARM) return;

  try {
    const token = await getAccessToken();
    if (token) {
      console.log("[auth] Token refreshed silently");
    } else {
      console.warn("[auth] Silent refresh failed — user must re-authenticate");
      chrome.action.setBadgeText({ text: "!" });
      chrome.action.setBadgeBackgroundColor({ color: "#e53935" });
    }
  } catch (error) {
    console.error("[auth] Token refresh error:", error);
  }
});

// Also refresh on service worker startup (wake from idle)
chrome.runtime.onStartup.addListener(async () => {
  await getAccessToken(); // Triggers refresh if expired
});

Gotcha: Alarm Minimum Interval

chrome.alarms enforces a minimum period of 1 minute for unpacked extensions and 1 minute for packed. You cannot use alarms for sub-minute refresh checks. If your tokens expire faster than that, refresh them on-demand before each API call instead.


Pattern 7: Logout and Token Revocation

A proper logout must revoke tokens server-side, clear local state, and update the UI:

// lib/logout.ts

import { clearTokens, getAccessToken } from "./token-store";

interface LogoutOptions {
  revokeRemote?: boolean;
  clearUserData?: boolean;
}

export async function logout(
  options: LogoutOptions = { revokeRemote: true, clearUserData: true }
): Promise<void> {
  // 1. Revoke token server-side (prevents use even if leaked)
  if (options.revokeRemote) {
    await revokeRemoteToken().catch((err) => {
      console.warn("[auth] Remote revocation failed:", err);
      // Continue logout even if revocation fails
    });
  }

  // 2. Clear Chrome's cached Google token (if using getAuthToken)
  try {
    const result = await chrome.identity.getAuthToken({ interactive: false });
    if (result.token) {
      await chrome.identity.removeCachedAuthToken({ token: result.token });
    }
  } catch {
    // Not using Google auth — skip
  }

  // 3. Clear stored tokens
  await clearTokens();

  // 4. Optionally clear user data
  if (options.clearUserData) {
    await chrome.storage.local.remove([
      "authState",
      "accountStore",
      "userPreferences",
    ]);
  }

  // 5. Clear any auth-related badge
  chrome.action.setBadgeText({ text: "" });

  // 6. Broadcast logout to all contexts
  chrome.runtime.sendMessage({ type: "LOGGED_OUT" }).catch(() => {});
}

async function revokeRemoteToken(): Promise<void> {
  const token = await getAccessToken();
  if (!token) return;

  // Google revocation endpoint
  await fetch(`https://oauth2.googleapis.com/revoke?token=${token}`, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
  });
}

Why Revoke Remotely?

Clearing local tokens is not enough. If the token was exfiltrated (XSS, compromised dependency), it remains valid until it expires. Remote revocation invalidates it immediately. Always revoke on logout.


Pattern 8: Auth-Gated UI

The popup should render different views based on authentication state — a login screen for unauthenticated users, a dashboard for authenticated ones:

// popup/popup.ts

import type { AuthState } from "../lib/auth-state";

async function initPopup(): Promise<void> {
  const result = await chrome.storage.local.get("authState");
  const authState: AuthState = result.authState ?? { status: "signed-out" };
  renderForState(authState);

  // Listen for state changes while popup is open
  chrome.runtime.onMessage.addListener((message) => {
    if (message.type === "AUTH_STATE_CHANGED") {
      renderForState(message.state);
    }
  });
}

function renderForState(state: AuthState): void {
  const app = document.getElementById("app")!;

  switch (state.status) {
    case "signed-out":
      app.innerHTML = `
        <div class="login-view">
          <h2>Welcome</h2>
          <p>Sign in to get started.</p>
          <button id="btn-google">Sign in with Google</button>
          <button id="btn-github">Sign in with GitHub</button>
        </div>
      `;
      document.getElementById("btn-google")!.addEventListener("click", () => {
        chrome.runtime.sendMessage({ type: "SIGN_IN", provider: "google" });
      });
      document.getElementById("btn-github")!.addEventListener("click", () => {
        chrome.runtime.sendMessage({ type: "SIGN_IN", provider: "github" });
      });
      break;

    case "signing-in":
      app.innerHTML = `
        <div class="loading-view">
          <div class="spinner"></div>
          <p>Signing in...</p>
        </div>
      `;
      break;

    case "signed-in":
      app.innerHTML = `
        <div class="dashboard-view">
          <div class="user-header">
            ${state.user.avatarUrl
              ? `<img src="${state.user.avatarUrl}" alt="" width="32" />`
              : ""}
            <span>${state.user.displayName}</span>
          </div>
          <div class="dashboard-content">
            <!-- Extension features go here -->
          </div>
          <button id="btn-logout">Sign out</button>
        </div>
      `;
      document.getElementById("btn-logout")!.addEventListener("click", () => {
        chrome.runtime.sendMessage({ type: "SIGN_OUT" });
      });
      break;

    case "error":
      app.innerHTML = `
        <div class="error-view">
          <p class="error-text">${state.error}</p>
          <button id="btn-retry">Try again</button>
        </div>
      `;
      document.getElementById("btn-retry")!.addEventListener("click", () => {
        chrome.runtime.sendMessage({
          type: "SIGN_IN",
          provider: state.provider ?? "google",
        });
      });
      break;
  }
}

initPopup();

The background script handles the SIGN_IN and SIGN_OUT messages and dispatches events to the auth state machine. The popup is purely a renderer — it reads state and sends commands, nothing else.


Summary

Pattern Problem It Solves
getAuthToken for Google One-click Google sign-in with Chrome-managed tokens
launchWebAuthFlow for others OAuth with GitHub, Twitter, or any provider
Token storage and refresh Secure split storage with automatic renewal
Typed auth state machine Eliminates impossible UI states during auth flows
Multi-account support Managing multiple identities with account switching
Silent service worker refresh Keeping tokens fresh despite SW termination
Logout and revocation Proper cleanup that invalidates tokens server-side
Auth-gated UI Popup renders login vs. dashboard based on state

Authentication in extensions is harder than in web apps because the runtime is split across contexts and the service worker is ephemeral. Model your auth flow as an explicit state machine, split token storage between session and local, and always revoke tokens on logout. The chrome.identity API handles the OAuth dance, but token lifecycle and state management are your responsibility. -e —

Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.