Chrome Extension Authentication: OAuth, Credential Management and Session Sync (2026)
Authentication in Chrome extensions is one of those areas where developers quickly discover the rules are different. There’s no server-rendered session cookie, no traditional login page redirect, and no single obvious place to store a user’s identity. Yet users expect extensions to remember who they are — across tabs, windows, restarts, and even devices.
This guide walks through every layer of extension authentication: from Google OAuth via chrome.identity to custom OAuth 2.0 flows, token refresh strategies, cross-device session sync, and the Credential Management API in content scripts. By the end you’ll have patterns you can drop directly into a production extension.
Why Authentication in Extensions is Unique
A traditional web app authenticates against a server and stores session state in an HTTP-only cookie. The browser handles renewal automatically on each request. Extensions don’t have that luxury.
- No persistent HTTP session: The service worker (background script) is ephemeral — it spins up on demand and can be killed at any time. Any in-memory state is gone.
- Multiple execution contexts: Your popup, content scripts, options page, and service worker all run in isolated JS contexts. Sharing auth state between them requires an explicit messaging or storage layer.
- Origin restrictions: Extensions have their own
chrome-extension://origin. OAuth providers must explicitly allowlist this origin or redirect URI. - Silent renewal is hard: You can’t rely on
Set-Cookieheaders from a background fetch to maintain session. Token refresh must be intentional code you write.
Understanding these constraints shapes every decision below.
chrome.identity API for Google OAuth
For Google-specific sign-in, chrome.identity.getAuthToken is the simplest path. Chrome handles the OAuth dance entirely — including showing the Google account picker — and returns an access token you can use immediately.
manifest.json
{
"permissions": ["identity"],
"oauth2": {
"client_id": "YOUR_CLIENT_ID.apps.googleusercontent.com",
"scopes": [
"openid",
"email",
"profile"
]
}
}Getting a token in the service worker or popup
// auth/google-identity.js
export async function getGoogleAccessToken(interactive = false) {
return new Promise((resolve, reject) => {
chrome.identity.getAuthToken({ interactive }, (token) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
resolve(token);
});
});
}
export async function revokeGoogleToken(token) {
return new Promise((resolve) => {
chrome.identity.removeCachedAuthToken({ token }, () => {
// Also hit the revoke endpoint so Google invalidates server-side
fetch(`https://accounts.google.com/o/oauth2/revoke?token=${token}`);
resolve();
});
});
}interactive: false performs a silent token fetch — useful on startup to check if the user is already signed in. interactive: true triggers the account picker UI if no cached token exists.
Fetching user profile after sign-in
// auth/google-profile.js
export async function fetchGoogleProfile(accessToken) {
const res = await fetch("https://www.googleapis.com/oauth2/v3/userinfo", {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) throw new Error(`Profile fetch failed: ${res.status}`);
return res.json(); // { sub, name, email, picture }
}One gotcha: getAuthToken tokens expire after one hour. Chrome caches them, so subsequent calls within that window return instantly. After expiry, pass interactive: false first — if it fails, then prompt with interactive: true.
Custom OAuth 2.0 Flows with launchWebAuthFlow
When your backend uses a non-Google provider (GitHub, Microsoft, Okta, Auth0, or your own server), use chrome.identity.launchWebAuthFlow. It opens a browser popup, lets the user authenticate, and returns the redirect URL with the authorization code.
Setting up the redirect URI
The redirect URI must match what your OAuth provider has registered. For extensions, use:
https://<extension-id>.chromiumapp.org/callbackYou can get the extension ID dynamically:
const redirectUri = chrome.identity.getRedirectURL("callback");
// e.g. https://abcdefghijklmno.chromiumapp.org/callbackPKCE flow implementation
Always use PKCE (Proof Key for Code Exchange) for public clients — extensions cannot safely store a client secret.
// auth/pkce.js
export function generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return btoa(String.fromCharCode(...array))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
export async function generateCodeChallenge(verifier) {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await crypto.subtle.digest("SHA-256", data);
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}Launching the auth flow
// auth/oauth-flow.js
import { generateCodeVerifier, generateCodeChallenge } from "./pkce.js";
export async function launchOAuthFlow(config) {
const { authEndpoint, clientId, scopes } = config;
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
const state = crypto.randomUUID(); // CSRF protection
const redirectUri = chrome.identity.getRedirectURL("callback");
// Persist verifier and state for validation after redirect
await chrome.storage.session.set({ pkce_verifier: verifier, oauth_state: state });
const params = new URLSearchParams({
response_type: "code",
client_id: clientId,
redirect_uri: redirectUri,
scope: scopes.join(" "),
state,
code_challenge: challenge,
code_challenge_method: "S256",
});
const authUrl = `${authEndpoint}?${params}`;
return new Promise((resolve, reject) => {
chrome.identity.launchWebAuthFlow(
{ url: authUrl, interactive: true },
async (responseUrl) => {
if (chrome.runtime.lastError || !responseUrl) {
reject(new Error(chrome.runtime.lastError?.message ?? "Auth cancelled"));
return;
}
const url = new URL(responseUrl);
const code = url.searchParams.get("code");
const returnedState = url.searchParams.get("state");
const { oauth_state, pkce_verifier } = await chrome.storage.session.get([
"oauth_state",
"pkce_verifier",
]);
if (returnedState !== oauth_state) {
reject(new Error("State mismatch — possible CSRF attack"));
return;
}
resolve({ code, verifier: pkce_verifier, redirectUri });
}
);
});
}After getting the code, exchange it for tokens on your backend — never from the extension directly, as the client secret must stay server-side.
Token Storage and Refresh Patterns
Never store raw tokens in localStorage — content scripts from any page can read it. Use chrome.storage.local (persistent) or chrome.storage.session (cleared when browser closes).
// auth/token-store.js
const TOKEN_KEY = "auth_tokens";
export async function saveTokens({ accessToken, refreshToken, expiresIn }) {
const expiresAt = Date.now() + expiresIn * 1000;
await chrome.storage.local.set({
[TOKEN_KEY]: { accessToken, refreshToken, expiresAt },
});
}
export async function loadTokens() {
const result = await chrome.storage.local.get(TOKEN_KEY);
return result[TOKEN_KEY] ?? null;
}
export async function clearTokens() {
await chrome.storage.local.remove(TOKEN_KEY);
}
export function isTokenExpired(tokens, bufferSeconds = 60) {
return Date.now() >= tokens.expiresAt - bufferSeconds * 1000;
}Automatic refresh in the service worker
// auth/token-refresh.js
import { loadTokens, saveTokens, isTokenExpired } from "./token-store.js";
export async function getValidAccessToken(backendUrl) {
const tokens = await loadTokens();
if (!tokens) return null;
if (!isTokenExpired(tokens)) return tokens.accessToken;
// Token is near expiry — refresh it
const res = await fetch(`${backendUrl}/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: tokens.refreshToken }),
});
if (!res.ok) {
await clearTokens();
return null; // Force re-login
}
const refreshed = await res.json();
await saveTokens(refreshed);
return refreshed.accessToken;
}Schedule a periodic alarm to refresh proactively so users are never surprised by a mid-session logout:
// service-worker.js
chrome.alarms.create("token-refresh", { periodInMinutes: 30 });
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name === "token-refresh") {
await getValidAccessToken(BACKEND_URL);
}
});Cross-Device Session Sync with chrome.storage.sync
chrome.storage.sync replicates data across devices where the user is signed into Chrome. It’s ideal for persisting a user identifier or lightweight session metadata so users don’t have to sign in on each device.
// auth/session-sync.js
export async function persistSessionMetadata(userId, email) {
// Store only non-sensitive identifiers — never raw tokens
await chrome.storage.sync.set({
session: { userId, email, lastSeen: Date.now() },
});
}
export async function loadSessionMetadata() {
const result = await chrome.storage.sync.get("session");
return result.session ?? null;
}
export async function clearSessionMetadata() {
await chrome.storage.sync.remove("session");
}For the actual tokens (sensitive), keep them in chrome.storage.local only — they should not roam. The sync layer carries just enough to show the user’s name and email in the popup UI, and to fast-path the silent token fetch on a new device.
Listen for sync changes so all extension windows stay consistent:
chrome.storage.onChanged.addListener((changes, area) => {
if (area === "sync" && changes.session) {
// Notify popup or options page to re-render
chrome.runtime.sendMessage({ type: "SESSION_UPDATED", session: changes.session.newValue });
}
});Credential Management API in Content Scripts
When your extension overlays a login form on a webpage, you can tap the browser’s Credential Management API to offer saved credentials — similar to how password managers work.
// content-scripts/credential-helper.js
export async function requestSavedCredential() {
if (!navigator.credentials) return null;
try {
const credential = await navigator.credentials.get({
password: true,
mediation: "optional", // "silent" skips the account chooser UI
});
if (credential instanceof PasswordCredential) {
return { id: credential.id, password: credential.password };
}
return null;
} catch {
return null;
}
}
export async function storeCredential(id, password) {
if (!navigator.credentials) return;
const cred = new PasswordCredential({ id, password });
await navigator.credentials.store(cred);
}This API runs in the page’s origin context (injected content script), so credentials are stored against the page domain — not the extension. That means they show up in the browser’s saved passwords list and can be autofilled naturally. Call storeCredential after a successful login to enroll new credentials without asking the user to re-enter them.
Building a Login/Signup Popup Flow
The popup is stateless — it re-renders from scratch every time the user clicks the extension icon. Structure it around a quick auth check on load.
// popup/popup.js
import { loadTokens, isTokenExpired } from "../auth/token-store.js";
import { loadSessionMetadata } from "../auth/session-sync.js";
async function init() {
const tokens = await loadTokens();
const session = await loadSessionMetadata();
if (tokens && !isTokenExpired(tokens) && session) {
renderAuthenticatedView(session);
} else {
renderLoginView();
}
}
function renderAuthenticatedView(session) {
document.getElementById("root").innerHTML = `
<div class="user-info">
<p>Signed in as <strong>${session.email}</strong></p>
<button id="sign-out">Sign out</button>
</div>
`;
document.getElementById("sign-out").addEventListener("click", handleSignOut);
}
function renderLoginView() {
document.getElementById("root").innerHTML = `
<div class="login">
<button id="sign-in-google">Sign in with Google</button>
<button id="sign-in-custom">Sign in with your account</button>
</div>
`;
document.getElementById("sign-in-google").addEventListener("click", handleGoogleSignIn);
}
async function handleSignOut() {
// Message the service worker to clear tokens and revoke
await chrome.runtime.sendMessage({ type: "SIGN_OUT" });
renderLoginView();
}
document.addEventListener("DOMContentLoaded", init);Keep the popup handler thin — delegate all token logic to the service worker via chrome.runtime.sendMessage. This prevents token state from living in the ephemeral popup context.
Security Considerations
Always use PKCE. Extensions are public clients. There’s no safe place to store a client secret, so the authorization code flow without PKCE is vulnerable to interception. PKCE ensures even if an attacker captures the redirect, they cannot exchange the code for tokens.
Validate the state parameter. Generate a cryptographically random state before launching launchWebAuthFlow, store it in chrome.storage.session, and verify it matches on return. This prevents CSRF attacks where a malicious page tries to inject an authorization response.
Set short token lifetimes. Access tokens should expire in 15–60 minutes. Refresh tokens should be rotated on each use (refresh token rotation) so stolen refresh tokens are invalidated quickly.
Avoid logging tokens. console.log output is visible in DevTools to anyone who can open the extension’s background service worker inspector. Strip token values from any logging.
Scope minimization. Request only the OAuth scopes your extension actually needs. Broad scopes increase the blast radius if tokens are compromised and trigger more aggressive OAuth consent screens that reduce conversion.
Content Security Policy. Restrict your extension’s CSP to block inline scripts and only allowlist the specific domains your auth endpoints live on:
{
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'none'; connect-src https://auth.yourservice.com https://accounts.google.com"
}
}Token encryption at rest. For high-security extensions, encrypt tokens before writing to chrome.storage.local using the Web Crypto API. Use a key derived from a user-provided PIN or a hardware-bound key via the WebAuthn API.
Putting It All Together
A production auth layer in a Chrome extension typically looks like this:
- On install/startup: Silent token fetch (
interactive: false) usingchrome.identityor load stored tokens and check expiry. - If no valid token: Render login UI in popup, launch OAuth flow on user action.
- After successful auth: Store tokens in
chrome.storage.local, store session metadata inchrome.storage.sync, notify all extension contexts viachrome.runtime.sendMessage. - On each API call: Call
getValidAccessToken()which transparently refreshes if needed. - On sign-out: Clear both storage areas, revoke tokens server-side, re-render login UI.
This separation — ephemeral service worker handling token logic, sync storage carrying session metadata, local storage holding sensitive tokens — mirrors how a native app separates keychain storage from user defaults. The result is an extension that feels seamlessly authenticated across sessions and devices without compromising security.
Authentication done right is invisible to the user. They sign in once, and your extension just works — today, tomorrow, and on their other laptop. That’s the standard worth building to.
Building a Chrome extension? ExtensionBooster helps you grow your user base with reviews, ratings, and visibility tools tailored for the Chrome Web Store.
Share this article
Build better extensions with free tools
Icon generator, MV3 converter, review exporter, and more — no signup needed.
Related Articles
I Built the Same Chrome Extension With 5 Different Frameworks. Here's What Actually Happened.
WXT vs Plasmo vs CRXJS vs Extension.js vs Bedframe. Real benchmarks, honest opinions, and the framework with 12K stars that's quietly dying.
5 Best Email Marketing Services to Grow Your Chrome Extension (2026)
Compare the top email marketing platforms for SaaS and Chrome extension developers. MailerLite, Mailchimp, Brevo, ActiveCampaign, and Drip reviewed.
15 Best Practices to Build a Browser Extension That Users Love (2026 Guide)
Master browser extension development in 2026. Manifest V3, security, performance, and UX best practices to build extensions users love.