OAuth2 Authentication in Chrome Extensions — Developer Guide
25 min readOAuth2 Authentication in Chrome Extensions
Overview
Implementing authentication in Chrome extensions requires understanding the chrome.identity API, which provides two primary methods: getAuthToken for Google APIs and launchWebAuthFlow for third-party OAuth providers. This guide covers both approaches, token management, secure storage, error handling, and logout flows.
Prerequisites
You’ll need:
- A Chrome extension project with a background service worker
- An OAuth client ID from your provider (Google Developer Console, Auth0, Okta, etc.)
- The
identitypermission in yourmanifest.json
{
"permissions": ["identity"],
"oauth2": {
"client_id": "YOUR_CLIENT_ID.apps.googleusercontent.com",
"scopes": ["https://www.googleapis.com/auth/drive.readonly"]
}
}
The chrome.identity API
Chrome provides the chrome.identity API specifically for authentication in extensions. It handles the complexity of user authentication while keeping tokens secure.
Two Authentication Methods
| Method | Use Case | Token Handling |
|---|---|---|
getAuthToken |
Google APIs only | Chrome manages tokens automatically |
launchWebAuthFlow |
Any OAuth2/OAuth provider | You receive the auth code, handle tokens yourself |
Google APIs: Using getAuthToken
For Google APIs (Drive, Gmail, Calendar, etc.), getAuthToken is the simplest approach. Chrome handles token caching and refresh automatically.
Basic Usage
// background.ts
async function getGoogleAccessToken(): Promise<string | undefined> {
try {
const token = await chrome.identity.getAuthToken({
interactive: false, // Show UI if needed to get consent
});
return token;
} catch (error) {
console.error("Failed to get auth token:", error);
return undefined;
}
}
Interactive vs Non-Interactive
// Non-interactive (silent) - returns token if cached, undefined otherwise
const silentToken = await chrome.identity.getAuthToken({ interactive: false });
// Interactive - shows account picker or consent if needed
const interactiveToken = await chrome.identity.getAuthToken({ interactive: true });
Using the Token
async function listGoogleDriveFiles(): Promise<void> {
const token = await getGoogleAccessToken();
if (!token) {
throw new Error("Not authenticated");
}
const response = await fetch(
"https://www.googleapis.com/drive/v3/files?pageSize=10",
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
const data = await response.json();
console.log("Files:", data.files);
}
Handling Token Expiration
Chrome automatically caches tokens, but they expire. Use getAuthToken with interactive: false to get a fresh token:
async function getValidToken(): Promise<string | undefined> {
// Clear cached token first to force refresh
const token = await chrome.identity.getAuthToken({ interactive: false });
return token;
}
Or use the tokenDetails parameter to specify exact scopes:
const token = await chrome.identity.getAuthToken({
interactive: false,
scopes: ["https://www.googleapis.com/auth/drive.readonly"],
});
Third-Party OAuth: Using launchWebAuthFlow
For non-Google OAuth providers (Auth0, Okta, your own OAuth server), use launchWebAuthFlow. This opens a popup where users authenticate, then returns an auth code or access token.
Basic Flow
// background.ts
const CLIENT_ID = "your-client-id";
const REDIRECT_URI = chrome.identity.getRedirectURL();
const AUTH_URL = `https://your-oauth-provider.com/authorize?client_id=${CLIENT_ID}&redirect_uri=${encodeURIComponent(REDIRECT_URI)}&response_type=code&scope=read:profile`;
async function authenticateWithOAuth(): Promise<string | undefined> {
try {
const responseUrl = await chrome.identity.launchWebAuthFlow({
url: AUTH_URL,
interactive: true,
});
if (!responseUrl) {
throw new Error("Authentication was cancelled");
}
// Parse the code from the redirect URL
const url = new URL(responseUrl);
const code = url.searchParams.get("code");
return code;
} catch (error) {
console.error("Auth flow failed:", error);
return undefined;
}
}
Complete Example with Token Exchange
// background.ts
interface OAuthConfig {
clientId: string;
authEndpoint: string;
tokenEndpoint: string;
redirectUri: string;
scopes: string[];
}
class OAuthManager {
constructor(private config: OAuthConfig) {}
private buildAuthUrl(): string {
const params = new URLSearchParams({
client_id: this.config.clientId,
redirect_uri: this.config.redirectUri,
response_type: "code",
scope: this.config.scopes.join(" "),
state: this.generateState(),
});
return `${this.config.authEndpoint}?${params.toString()}`;
}
private generateState(): string {
return Math.random().toString(36).substring(2, 15);
}
async startAuthFlow(): Promise<{ accessToken: string; refreshToken?: string } | null> {
try {
const authUrl = this.buildAuthUrl();
const responseUrl = await chrome.identity.launchWebAuthFlow({
url: authUrl,
interactive: true,
});
if (!responseUrl) {
return null;
}
// Extract authorization code from redirect URL
const url = new URL(responseUrl);
const code = url.searchParams.get("code");
if (!code) {
// Some providers return token directly in hash
const hash = url.hash.substring(1);
const params = new URLSearchParams(hash);
const accessToken = params.get("access_token");
if (accessToken) {
return { accessToken };
}
return null;
}
// Exchange code for tokens (do this server-side in production!)
return await this.exchangeCodeForTokens(code);
} catch (error) {
console.error("OAuth flow failed:", error);
return null;
}
}
private async exchangeCodeForTokens(
code: string
): Promise<{ accessToken: string; refreshToken?: string }> {
const response = await fetch(this.config.tokenEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "authorization_code",
code,
client_id: this.config.clientId,
redirect_uri: this.config.redirectUri,
}),
});
if (!response.ok) {
throw new Error(`Token exchange failed: ${response.statusText}`);
}
const tokens = await response.json();
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
};
}
}
// Usage
const oauth = new OAuthManager({
clientId: "your-client-id",
authEndpoint: "https://auth.example.com/authorize",
tokenEndpoint: "https://auth.example.com/token",
redirectUri: chrome.identity.getRedirectURL(),
scopes: ["read:profile", "write:data"],
});
const tokens = await oauth.startAuthFlow();
if (tokens) {
console.log("Authenticated! Access token:", tokens.accessToken);
}
Token Management and Refresh
Storing Tokens Securely
Never store tokens in localStorage or plain text. Use chrome.storage.session for access tokens and chrome.storage.local with encryption for refresh tokens:
// auth-storage.ts
import { encrypt, decrypt } from "./crypto-utils"; // Your encryption utility
const TOKEN_KEY = "auth_tokens";
interface TokenData {
accessToken: string;
refreshToken?: string;
expiresAt?: number; // Unix timestamp
provider: string;
}
async function storeTokens(data: TokenData): Promise<void> {
// Store access token in session storage (cleared when browser closes)
await chrome.storage.session.set({
accessToken: data.accessToken,
expiresAt: data.expiresAt,
});
// Store refresh token in local storage with encryption
if (data.refreshToken) {
const encrypted = await encrypt(data.refreshToken);
await chrome.storage.local.set({
refreshToken: encrypted,
provider: data.provider,
});
}
}
async function getAccessToken(): Promise<string | undefined> {
const { accessToken, expiresAt } = await chrome.storage.session.get([
"accessToken",
"expiresAt",
]);
// Check if token is expired
if (expiresAt && Date.now() > expiresAt) {
// Token expired, try to refresh
const refreshed = await refreshAccessToken();
return refreshed;
}
return accessToken;
}
async function getRefreshToken(): Promise<string | undefined> {
const { refreshToken, provider } = await chrome.storage.local.get([
"refreshToken",
"provider",
]);
if (!refreshToken) return undefined;
return await decrypt(refreshToken);
}
async function refreshAccessToken(): Promise<string | undefined> {
const refreshToken = await getRefreshToken();
if (!refreshToken) {
// No refresh token, need to re-authenticate
return undefined;
}
// Call your token refresh endpoint
const response = await fetch("https://auth.example.com/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: "your-client-id",
}),
});
if (!response.ok) {
// Refresh failed, clear tokens
await clearTokens();
return undefined;
}
const tokens = await response.json();
// Store new tokens
await storeTokens({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
expiresAt: Date.now() + tokens.expires_in * 1000,
provider: "example",
});
return tokens.access_token;
}
async function clearTokens(): Promise<void> {
await chrome.storage.session.clear();
await chrome.storage.local.remove(["refreshToken", "provider"]);
}
Token Refresh Logic
Implement automatic token refresh before making API calls:
// api-client.ts
async function makeAuthenticatedRequest(
url: string,
options: RequestInit = {}
): Promise<Response> {
let token = await getAccessToken();
// If no token or needs refresh, try to refresh
if (!token) {
token = await refreshAccessToken();
}
if (!token) {
throw new Error("Authentication required");
}
const response = await fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${token}`,
},
});
// Handle 401 - token might have been revoked
if (response.status === 401) {
token = await refreshAccessToken();
if (token) {
return fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${token}`,
},
});
}
throw new Error("Authentication failed");
}
return response;
}
Handling Auth Errors
Common Error Types
// error-handler.ts
type AuthError =
| { type: "not_authenticated" }
| { type: "token_expired" }
| { type: "token_revoked" }
| { type: "permission_denied" }
| { type: "network_error" }
| { type: "unknown"; message: string };
async function handleAuthError(error: unknown): Promise<AuthError> {
if (error instanceof Error) {
// Chrome identity errors
if (error.message.includes("OAuth2")) {
return { type: "permission_denied" };
}
// Network errors
if (error.message.includes("network")) {
return { type: "network_error" };
}
return { type: "unknown"; message: error.message };
}
return { type: "unknown"; message: "Unknown error" };
}
// In your code
async function handleApiError(error: unknown): Promise<void> {
const authError = await handleAuthError(error);
switch (authError.type) {
case "not_authenticated":
case "token_expired":
case "token_revoked":
// Clear tokens and prompt re-authentication
await clearTokens();
// Notify UI to show login button
chrome.runtime.sendMessage({ type: "AUTH_REQUIRED" });
break;
case "permission_denied":
console.error("User denied permission");
break;
case "network_error":
console.error("Network error, will retry");
break;
default:
console.error("Unknown error:", authError.message);
}
}
Graceful Degradation
// graceful-auth.ts
class AuthManager {
private isRefreshing = false;
private refreshPromise: Promise<string | undefined> | null = null;
async getValidToken(): Promise<string | undefined> {
if (this.isRefreshing && this.refreshPromise) {
return this.refreshPromise;
}
this.isRefreshing = true;
this.refreshPromise = this.performRefresh();
try {
return await this.refreshPromise;
} finally {
this.isRefreshing = false;
this.refreshPromise = null;
}
}
private async performRefresh(): Promise<string | undefined> {
// First try to get cached token
let token = await getAccessToken();
if (!token) {
// Try refresh token
token = await refreshAccessToken();
}
return token;
}
// Queue API calls while refreshing
private requestQueue: Array<() => void> = [];
async queueRequest<T>(request: () => Promise<T>): Promise<T> {
const token = await this.getValidToken();
if (!token) {
throw new Error("Authentication required");
}
return request();
}
}
Logout Flow
Implement a complete logout that clears all stored credentials:
// logout.ts
async function logout(): Promise<void> {
// 1. Clear all stored tokens
await clearTokens();
// 2. Revoke Google token (if using Google APIs)
try {
const { accessToken } = await chrome.storage.session.get("accessToken");
if (accessToken) {
await fetch(
`https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(accessToken)}`
);
}
} catch (error) {
console.error("Failed to revoke Google token:", error);
}
// 3. Notify all extension contexts
chrome.runtime.sendMessage({ type: "LOGGED_OUT" });
// 4. Update badge or icon to show logged out state
chrome.action.setBadgeText({ text: "" });
}
// Listen for logout in popup/options
chrome.runtime.onMessage.addListener((message) => {
if (message.type === "LOGGED_OUT") {
// Update UI to show logged out state
updateUI({ isLoggedIn: false });
}
});
Logout from Popup
// popup.ts
document.getElementById("logout-btn")?.addEventListener("click", async () => {
await logout();
// Show login button, hide user info
document.getElementById("login-section")?.classList.remove("hidden");
document.getElementById("user-section")?.classList.add("hidden");
});
Security Best Practices
Token Security
- Never log tokens — Tokens in console logs can be exploited
- Use HTTPS always — Never send tokens over HTTP
- Implement token expiration — Don’t trust tokens indefinitely
- Encrypt refresh tokens — Use
chrome.storage.localwith encryption for long-lived tokens - Clear tokens on logout — Ensure complete token cleanup
CSRF Protection
// Use state parameter to prevent CSRF
function generateState(): string {
const array = new Uint8Array(16);
crypto.getRandomValues(array);
return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join(
""
);
}
// Store state in session storage to verify on callback
async function startAuth(): Promise<void> {
const state = generateState();
await chrome.storage.session.set({ oauthState: state });
const authUrl = buildAuthUrl(state);
await chrome.identity.launchWebAuthFlow({ url: authUrl });
}
// Verify state on callback
async function handleCallback(url: string): Promise<boolean> {
const { oauthState } = await chrome.storage.session.get("oauthState");
const urlState = new URL(url).searchParams.get("state");
return oauthState === urlState;
}
Manifest V3 Considerations
Service Worker vs Background Page
In Manifest V3, background scripts run as service workers with these implications:
- No persistent state — Use
chrome.storageinstead of variables - ** Ephemeral execution** — Service worker can be terminated
- No synchronous XHR — Use
fetchwith async/await
// background.ts (Manifest V3)
// Token stored in chrome.storage, not closure variable
chrome.runtime.onStartup.addListener(async () => {
// Initialize on browser startup
const token = await getAccessToken();
if (token) {
chrome.action.setBadgeText({ text: "✓" });
}
});
content Script Auth Communication
Content scripts cannot access chrome.identity directly. Use message passing:
// content.ts
async function authenticate(): Promise<void> {
const response = await chrome.runtime.sendMessage({
type: "GET_AUTH_TOKEN",
});
if (response?.token) {
// Use token for API calls
}
}
// background.ts
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === "GET_AUTH_TOKEN") {
getValidToken().then((token) => {
sendResponse({ token });
});
return true; // Keep message channel open for async response
}
});
Complete Example: Google Drive Extension
// background.ts
class GoogleDriveAuth {
private static readonly SCOPES = [
"https://www.googleapis.com/auth/drive.readonly",
];
async getToken(): Promise<string | null> {
try {
const token = await chrome.identity.getAuthToken({
interactive: false,
scopes: GoogleDriveAuth.SCOPES,
});
return token || null;
} catch (error) {
console.error("Failed to get token:", error);
return null;
}
}
async getTokenInteractive(): Promise<string | null> {
try {
const token = await chrome.identity.getAuthToken({
interactive: true,
scopes: GoogleDriveAuth.SCOPES,
});
return token || null;
} catch (error) {
console.error("Failed to get token (interactive):", error);
return null;
}
}
async removeToken(): Promise<void> {
const token = await chrome.identity.getAuthToken({ interactive: false });
if (token) {
await chrome.identity.removeCachedAuthToken({ token });
}
}
async listFiles(limit = 10): Promise<DriveFile[]> {
const token = await this.getToken();
if (!token) {
throw new Error("Not authenticated");
}
const response = await fetch(
`https://www.googleapis.com/drive/v3/files?pageSize=${limit}&fields=files(id,name,mimeType,modifiedTime)`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (!response.ok) {
if (response.status === 401) {
await this.removeToken();
throw new Error("Token expired");
}
throw new Error(`API error: ${response.statusText}`);
}
const data = await response.json();
return data.files;
}
}
// Usage
const drive = new GoogleDriveAuth();
// In popup or background
document.getElementById("list-files")?.addEventListener("click", async () => {
const token = await drive.getToken();
if (!token) {
// Need to authenticate
const newToken = await drive.getTokenInteractive();
if (!newToken) {
console.error("Authentication cancelled");
return;
}
}
try {
const files = await drive.listFiles();
console.log("Files:", files);
} catch (error) {
console.error("Failed to list files:", error);
}
});
Summary
- Use
getAuthTokenfor Google APIs — Chrome manages token caching and refresh - Use
launchWebAuthFlowfor third-party OAuth providers - Store tokens securely using
chrome.storage.sessionfor access tokens and encryptedchrome.storage.localfor refresh tokens - Implement automatic token refresh before API calls
- Handle errors gracefully with proper error types
- Clear all tokens and cached credentials on logout
- Follow security best practices: HTTPS, CSRF protection, token expiration
Related Articles
- Permissions Quickstart — Understanding Chrome extension permissions
- Chrome Storage Patterns — Advanced storage techniques for extensions
- Runtime API Guide — Chrome runtime API for extension communication
Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.