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.
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.
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.