Identity & OAuth Authentication
Sign users in from a Manifest V3 extension: launchWebAuthFlow with PKCE, redirect URLs per browser, token storage that survives worker eviction, and refresh without a secret.
An extension is a public OAuth client that cannot keep a secret. Its source ships to every user, so any client secret in the bundle is a published credential, and the redirect URL it must register is generated by the browser rather than chosen by you. Both facts break the copy-paste web tutorial you were about to follow. Add the Manifest V3 execution model on top — the worker holding your access token can be evicted between the request that fetched it and the request that needs it — and authentication becomes an architecture problem rather than a library call. This guide is part of Core APIs & Cross-Browser Data Management and covers the whole path from the manifest key to a token that is still valid an hour later.
Prerequisites checklist
Before writing a line of authentication code, confirm:
- The
identitypermission is declared. Without itchrome.identityisundefined, not an error you can catch. - Host permissions for the token endpoint — the code exchange is a
fetchfrom the worker, so the provider’s token host must be reachable.https://oauth2.googleapis.com/*and equivalents belong inhost_permissions. - A registered redirect URL per browser.
chrome.identity.getRedirectURL()returns a different origin in each engine, and every one of them must be listed with the provider. - The provider supports PKCE (RFC 7636). If it insists on a client secret for the authorization code grant, it is not usable directly from an extension — route the exchange through a server you control.
- A decision about where tokens live. chrome.storage.session versus local is the question that decides how much damage a compromised profile does.
1. Declare the surface in the manifest
The identity permission produces an install-time warning in Chrome, so declare it deliberately and be ready to justify it — see writing a permission justification that passes.
1{
2 "manifest_version": 3,
3 "name": "Sync Companion",
4 "version": "2.4.0",
5 "permissions": [
6 "identity", // chrome.identity.* — install warning: "Know who you are"
7 "storage" // storage.session for the access token
8 ],
9 "host_permissions": [
10 "https://api.example.com/*", // the resource server you call
11 "https://auth.example.com/*" // the token endpoint for the code exchange
12 ],
13 "background": { "service_worker": "sw.js", "type": "module" }
14}
Execution context: Read by the extension host at install and update time. Chrome and Edge surface identity as a user-visible warning; Firefox lists it but words the prompt differently; Safari maps it onto its own native entitlement model and shows nothing at install. The oauth2 manifest key some tutorials use is Chrome-specific and only relevant to getAuthToken — omit it if you are building the cross-browser flow described here.
2. Generate the PKCE pair before opening the window
PKCE replaces the client secret with a value your extension invents per attempt: a random code_verifier, and its SHA-256 hash sent up front as the code_challenge. An interceptor who steals the redirect can read the code but cannot exchange it without the verifier, which never left the worker.
1// sw-auth.js — no dependencies, uses only globals the worker has
2function base64UrlEncode(bytes) {
3 return btoa(String.fromCharCode(...new Uint8Array(bytes)))
4 .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
5}
6
7export async function createPkcePair() {
8 const random = crypto.getRandomValues(new Uint8Array(32));
9 const verifier = base64UrlEncode(random);
10 const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
11 return { verifier, challenge: base64UrlEncode(digest) };
12}
Execution context: Service worker. crypto.getRandomValues and crypto.subtle are available there — crypto.subtle requires a secure context, which extension pages and workers always are. btoa exists in the worker global scope in all three engines. Safari’s implementation of crypto.subtle.digest is identical; the only divergence you will meet is that Safari terminates the worker faster, so generate the pair and launch the flow in the same event turn rather than caching a verifier for later.
3. Launch the flow and read the code back
chrome.identity.launchWebAuthFlow opens a window the extension cannot script, watches for a navigation to the redirect URL, and resolves with that URL as a string. You parse the code out of it yourself.
1// sw-auth.js
2const AUTHORIZE = "https://auth.example.com/oauth2/authorize";
3const CLIENT_ID = "ext-public-client";
4
5export async function signIn({ interactive = true } = {}) {
6 const { verifier, challenge } = await createPkcePair();
7 const redirectUri = chrome.identity.getRedirectURL("oauth2");
8 const state = crypto.randomUUID();
9
10 const url = new URL(AUTHORIZE);
11 url.search = new URLSearchParams({
12 client_id: CLIENT_ID,
13 response_type: "code",
14 redirect_uri: redirectUri,
15 scope: "profile sync.read sync.write",
16 code_challenge: challenge,
17 code_challenge_method: "S256",
18 state,
19 }).toString();
20
21 const returned = await chrome.identity.launchWebAuthFlow({ url: url.href, interactive });
22 const params = new URL(returned).searchParams;
23
24 if (params.get("error")) throw new Error(`auth-denied: ${params.get("error")}`);
25 if (params.get("state") !== state) throw new Error("auth-state-mismatch");
26
27 return exchangeCode(params.get("code"), verifier, redirectUri);
28}
Execution context: Service worker, inside a user-gesture-initiated event such as chrome.action.onClicked or a message from the popup. With interactive: false the call rejects immediately when consent would be needed, which is the correct mode for a silent refresh at startup. Firefox implements browser.identity.launchWebAuthFlow with the same signature and native Promises; Safari supports it but closes the window more eagerly when the user switches apps, so treat rejection as “try again interactively” rather than “the user refused”. The full set of failure modes is covered in fixing OAuth redirect URI mismatches.
4. Exchange the code without a secret
The token request is an ordinary fetch from the worker. Note the absence of client_secret — the verifier is what authenticates the request.
1// sw-auth.js
2const TOKEN = "https://auth.example.com/oauth2/token";
3
4async function exchangeCode(code, verifier, redirectUri) {
5 const res = await fetch(TOKEN, {
6 method: "POST",
7 headers: { "Content-Type": "application/x-www-form-urlencoded" },
8 body: new URLSearchParams({
9 grant_type: "authorization_code",
10 client_id: CLIENT_ID,
11 code,
12 code_verifier: verifier,
13 redirect_uri: redirectUri,
14 }),
15 });
16
17 if (!res.ok) throw new Error(`token-exchange-failed: ${res.status}`);
18 const token = await res.json();
19
20 await chrome.storage.session.set({
21 auth: { access: token.access_token, expiresAt: Date.now() + token.expires_in * 1000 },
22 });
23 await chrome.storage.local.set({ refresh: token.refresh_token });
24 return token.access_token;
25}
Execution context: Service worker. The fetch needs a matching entry in host_permissions or it is blocked by the extension’s content security policy rather than by CORS, which produces a confusingly different error message. Splitting the two tokens between session and local storage is deliberate: the access token disappears when the browser closes, the refresh token survives so the user is not re-prompted daily. Refreshing and storing access tokens securely works through the trade-off and the encryption option.
5. Serve every caller from one gate
The mistake that produces duplicate consent windows is letting each caller check the token itself. Funnel every request through a single accessor that de-duplicates concurrent refreshes.
1// sw-auth.js
2let inFlight = null;
3
4export async function getAccessToken() {
5 const { auth } = await chrome.storage.session.get("auth");
6 if (auth && auth.expiresAt - Date.now() > 60_000) return auth.access;
7
8 // One refresh at a time, however many callers arrive during it.
9 inFlight ??= refreshAccessToken().finally(() => { inFlight = null; });
10 return inFlight;
11}
12
13chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
14 if (msg?.type !== "GET_TOKEN") return false;
15 getAccessToken().then(
16 (access) => sendResponse({ ok: true, access }),
17 (error) => sendResponse({ ok: false, error: String(error) }),
18 );
19 return true; // keep the port open for the async reply
20});
Execution context: Service worker; the listener is registered at the top level so a cold start still answers the popup’s first message. The inFlight variable lives in worker memory and is therefore lost on eviction — that is acceptable, because after eviction there are no concurrent callers to de-duplicate. Returning true from the listener is the MV3 requirement described in fixing message port closed before response errors; omit it and the popup receives undefined regardless of how the promise settles.
6. Deciding what the extension should hold
Authentication design in an extension starts with a question web applications rarely have to ask: which credential should live on the user’s machine at all? An extension is a public client — anyone can unpack it — and its storage is readable by anyone with access to the profile. What it holds should be the least powerful credential that makes the product work.
In practice that means three rules. Hold access tokens in memory-backed chrome.storage.session rather than on disk, so a stolen profile directory does not contain a working bearer token. Hold refresh tokens only when the product genuinely needs offline or background access, and prefer providers that rotate them on use. And never hold a credential that authenticates you rather than the user — a client secret or a service key belongs on a server, as explained in handling API keys without shipping them in the bundle.
Scope follows the same logic. Request the narrowest scopes that support the current feature set, and add scopes incrementally when a feature that needs them is enabled, rather than asking for everything at first sign-in. Users are more willing to grant a scope attached to a visible action, and a token with fewer scopes is less damaging if it leaks.
1async function tokenFor(scopes) {
2 const { grantedScopes = [] } = await chrome.storage.local.get("grantedScopes");
3 const missing = scopes.filter((s) => !grantedScopes.includes(s));
4 if (missing.length) return signInWithScopes([...grantedScopes, ...missing]); // incremental consent
5 return currentAccessToken();
6}
Execution context: the service worker, behind the single gate described in step 5. Tracking granted scopes locally lets the extension ask for more only when a feature needs them, and the sign-in flow itself is the one in implementing OAuth2 with launchWebAuthFlow.
Finally, plan sign-out as carefully as sign-in: clear the browser’s cached token, revoke the grant at the provider, and remove local state, in that order — the full sequence is in signing users out and revoking tokens.
It is also worth deciding early what the extension does when authentication fails in the background. A refresh that fails at three in the morning should not produce a notification or a popup; it should mark the session as needing attention, show a quiet badge, and wait for the user’s next deliberate action to prompt a new sign-in. Interactive flows need a user gesture anyway, so designing for “silently degraded until the user returns” matches both the platform’s rules and what users expect from a tool that lives in their browser all day.
MV3 constraints to design around
- No client secret, ever. The bundle is readable by anyone who installs it. A provider that requires a secret needs a server-side broker.
- The worker can die mid-flow.
launchWebAuthFlowkeeps the worker alive while its window is open in Chrome, but treat any state you hold across the call as disposable and re-derive it from storage. chrome.storage.sessionis capped at 10 MB and defaults toTRUSTED_CONTEXTSaccess, meaning content scripts cannot read it. That default is the reason to use it for tokens.- No
window, nolocalStoragein the worker. Every OAuth library written for the browser that toucheswindow.locationwill fail there. interactive: falsenever shows UI. Use it on startup; fall back to an interactive call only from a real user gesture, or the window is suppressed.- Redirect URLs are browser-generated. You cannot register
http://localhostand be done; each engine’sgetRedirectURL()value must be registered separately. - Tokens are not synced. Never put one in
chrome.storage.sync— it would be replicated to every device on the profile and counted against a 100 KB per-item quota.
Cross-browser notes
Chrome/Edge (baseline): Both launchWebAuthFlow and the Chrome-only getAuthToken are available. getRedirectURL() returns https://<extension-id>.chromiumapp.org/<path>, a URL that never resolves over the network — the browser intercepts the navigation. Because the extension ID is stable once published, this value can be registered before your first release by adding the key field to the manifest.
Firefox: browser.identity.launchWebAuthFlow works the same way, but there is no getAuthToken — it is a Chrome-and-Google-accounts convenience with no Firefox equivalent. getRedirectURL() returns a URL derived from the per-installation extension UUID, which differs on every user’s machine. Providers that require an exact redirect match therefore need a wildcard registration, which some refuse; the workaround is described in the redirect guide below. Firefox also resolves the flow with native Promises, so the browser.* namespace needs no polyfill here.
Safari: browser.identity.launchWebAuthFlow is supported in Safari 16.4 and later, with a redirect base generated by the containing app. The auth window is a Safari sheet that the system may dismiss when the user backgrounds the browser, producing a rejection that is indistinguishable from a cancellation — retry rather than treating it as a refusal. Safari’s aggressive worker reclamation makes the single-gate pattern in step 5 more important, not less: assume the token cache is cold on every event.
All engines: the code exchange is a plain fetch, so the provider must send permissive CORS headers to the extension origin or return the token without a preflight-triggering content type. A provider that only supports Content-Type: application/json on its token endpoint will trigger a preflight that some deployments have never configured.
Where to go next
The guides under this topic each take one decision further. Choosing between getAuthToken and launchWebAuthFlow covers when the Chrome shortcut is worth the portability cost. Implementing OAuth 2.0 with launchWebAuthFlow is the full walkthrough with the error branches this overview skips. Refreshing and storing access tokens securely covers rotation, revocation and what to do when the refresh token itself expires. Fixing OAuth redirect URI mismatches is the page to open when the provider rejects the callback.
Further guides in this topic
The guides below go deeper into specific identity and oauth authentication problems that the sections above only touch on — each one starts from a concrete symptom and ends with a way to verify the fix.
- OAuth in Firefox and Safari Extensions — Implement OAuth2 in Firefox and Safari web extensions without chrome.identity.getAuthToken — redirect URLs, PKCE, token storage and the differences from the Chrome flow.
- Signing Users Out and Revoking Tokens — Sign a user out of an MV3 extension properly — clear the cached identity token, revoke it at the provider, wipe local state, and handle the account-switch case.
- Authenticating Against Your Own Backend with JWTs — Authenticate an MV3 extension to your API: exchange an OAuth code for short-lived JWT access tokens and rotating refresh tokens, store them safely, attach them from the service worker, and verify on the server.
- Getting the Signed-In User’s Profile Info — Read the browser user’s email and id with chrome.identity.getProfileUserInfo: the identity.email permission, accountStatus ANY, empty results when sync is off, privacy implications and Firefox and Safari alternatives.
- Handling Multiple Accounts in an Extension — Support several signed-in accounts in an MV3 extension: per-account token storage, an active-account switcher, prompt=select_account, per-site account mapping, and separating Chrome profiles from service accounts.
- Using PKCE with launchWebAuthFlow — Implement the OAuth 2.0 authorisation code flow with PKCE in an MV3 extension: generate a verifier and S256 challenge with crypto.subtle, validate state, exchange the code without a client secret, and handle errors.
Related
- Implementing OAuth 2.0 with launchWebAuthFlow — the full flow with every error branch handled.
- Refreshing and storing access tokens securely — rotation, revocation, and where a refresh token belongs.
- Choosing between getAuthToken and launchWebAuthFlow — the portability trade-off in one decision.
- Chrome Storage API & Sync — the storage areas a token can live in and their guarantees.
- Extension Security & CSP Hardening — the policy that blocks the auth libraries you are used to.
- Up to the Core APIs & Cross-Browser Data Management overview.