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.
Table of Contents
Older extension tutorials use the OAuth implicit flow — the access token comes back in the URL fragment — or embed a client secret to exchange an authorisation code. Both are now discouraged: the implicit flow is deprecated in OAuth 2.1, and a client secret in an extension package is public. The modern answer for any public client, extensions included, is the authorisation code flow with PKCE (Proof Key for Code Exchange). The extension invents a one-time secret, sends a hash of it when starting sign-in, and proves it knows the original when redeeming the code. This guide implements it with chrome.identity.launchWebAuthFlow and the Web Crypto API. It belongs to identity and OAuth authentication.
How PKCE protects a public client
An authorisation code on its own is dangerous in a public client: anything that intercepts the redirect — a malicious extension watching navigation, a compromised redirect handler — could redeem it. PKCE binds the code to the client instance that requested it. Before redirecting the user, the extension generates a random code_verifier, computes code_challenge = BASE64URL(SHA-256(code_verifier)), and includes the challenge in the authorisation request. The identity provider stores the challenge with the issued code. When the extension redeems the code at the token endpoint, it sends the original verifier; the provider hashes it, compares, and only then issues tokens. An attacker with the code but not the verifier gets nothing. The verifier never leaves the extension until the token request, and it is used once.
Step-by-step: PKCE in the service worker
1. Generate the verifier and challenge
1// pkce.js
2const b64url = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes)))
3 .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
4
5export async function pkcePair() {
6 const random = crypto.getRandomValues(new Uint8Array(32));
7 const verifier = b64url(random); // 43 chars
8 const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
9 return { verifier, challenge: b64url(digest) };
10}
Execution context: the service worker or any extension page; crypto.getRandomValues and crypto.subtle are available in both. The verifier must be 43 to 128 characters from the unreserved URL set, which 32 random bytes in base64url satisfy. Always use the S256 method — the plain method sends the verifier itself as the challenge and defeats the point.
2. Build the authorisation URL with state
1export async function buildAuthUrl() {
2 const { verifier, challenge } = await pkcePair();
3 const state = crypto.randomUUID();
4 const redirectUri = chrome.identity.getRedirectURL("oauth2");
5 await chrome.storage.session.set({ pkce: { verifier, state, redirectUri, at: Date.now() } });
6 const url = new URL("https://accounts.example.com/oauth2/authorize");
7 url.search = new URLSearchParams({
8 response_type: "code",
9 client_id: "readable-extension",
10 redirect_uri: redirectUri,
11 scope: "openid email offline_access",
12 code_challenge: challenge,
13 code_challenge_method: "S256",
14 state,
15 });
16 return url.href;
17}
Execution context: the service worker. Persisting the verifier and state in chrome.storage.session means the flow survives the worker being evicted while the user types their password — launchWebAuthFlow can take minutes. getRedirectURL("oauth2") returns https://<extension-id>.chromiumapp.org/oauth2, which must be registered exactly with the provider; a path suffix lets you distinguish flows.
3. Run the flow and validate the response
1export async function authorize({ interactive = true } = {}) {
2 const url = await buildAuthUrl();
3 let redirect;
4 try {
5 redirect = new URL(await chrome.identity.launchWebAuthFlow({ url, interactive }));
6 } catch (err) {
7 // "The user did not approve access." / "Authorization page could not be loaded."
8 throw Object.assign(new Error(err.message), { code: "auth-cancelled" });
9 }
10 const { pkce } = await chrome.storage.session.get("pkce");
11 await chrome.storage.session.remove("pkce");
12 if (!pkce || redirect.searchParams.get("state") !== pkce.state) throw new Error("state mismatch");
13 if (redirect.searchParams.get("error")) throw new Error(redirect.searchParams.get("error_description") ?? "auth error");
14 return { code: redirect.searchParams.get("code"), ...pkce };
15}
Execution context: the service worker. launchWebAuthFlow resolves with the full redirect URL once the provider redirects to your chromiumapp.org address. Checking state defends against injected redirects; removing the stored verifier ensures it is used once. Closing the window rejects the promise — treat that as a cancellation, not an error worth reporting.
4. Exchange the code with the verifier
1export async function exchange({ code, verifier, redirectUri }) {
2 const res = await fetch("https://accounts.example.com/oauth2/token", {
3 method: "POST",
4 headers: { "Content-Type": "application/x-www-form-urlencoded" },
5 body: new URLSearchParams({
6 grant_type: "authorization_code",
7 client_id: "readable-extension",
8 code,
9 redirect_uri: redirectUri,
10 code_verifier: verifier,
11 }),
12 });
13 if (!res.ok) throw new Error(`token endpoint ${res.status}: ${await res.text()}`);
14 return res.json(); // { access_token, refresh_token, id_token, expires_in }
15}
Execution context: the service worker, with host permission for the token endpoint or CORS headers that allow the extension origin. No client_secret is sent. The redirect_uri must match the one used in the authorisation request byte for byte. If your design routes the exchange through your own backend (to mint your own tokens), send the code and verifier there instead, as in authenticating against your own backend with JWTs.
5. Use silent re-authorisation when possible
1try {
2 const grant = await authorize({ interactive: false }); // no window if the provider session is valid
3 await saveTokens(await exchange(grant));
4} catch {
5 showSignInButton(); // fall back to an interactive sign-in
6}
Execution context: the service worker. With interactive: false, launchWebAuthFlow completes only if the provider can redirect immediately without user interaction — typically when the user still has a session with the provider and has already consented. It fails fast otherwise. Combine with prompt=none on providers that support it. Refresh tokens are usually the better way to stay signed in; silent re-authorisation is the fallback when the refresh token is gone.
6. Prevent overlapping flows
1let inFlight = null;
2export function signInOnce() {
3 inFlight ??= authorize().then(exchange).then(saveTokens).finally(() => { inFlight = null; });
4 return inFlight;
5}
Execution context: the service worker. Two simultaneous sign-ins — a popup button and an expired-session handler — would overwrite each other’s stored verifier and state, making both fail. Coalescing them into one flow avoids the race. The same protection belongs in front of refresh.
7. Register redirect URIs for every build
1Chrome Web Store build https://abcdefghijklmnopabcdefghijklmnop.chromiumapp.org/oauth2
2Edge Add-ons build https://ponmlkjihgfedcbaponmlkjihgfedcba.chromiumapp.org/oauth2
3Unpacked (pinned key) same as Chrome Web Store
4Firefox https://<hash>.extensions.allizom.org/oauth2
Execution context: your identity provider’s client configuration. Every distinct extension id produces a distinct redirect URL. Pin the development id with the manifest key so development uses the production redirect, as described in pinning a stable extension id with the key field, and list every store build’s URL with the provider.
Common mistakes
- Using the
plainchallenge method. It offers no protection; useS256. - Keeping the verifier in a module variable. A worker restart mid-sign-in loses it.
- Skipping state validation. It is the defence against forged redirects.
- Reusing a verifier. Each flow needs a fresh pair.
- Unregistered redirect URLs for some builds. Sign-in fails only for users of that store.
Cross-browser variation
- Chrome / Edge:
launchWebAuthFlowwithchromiumapp.orgredirects;interactive: falsefor silent flows. Recent Chrome versions add options for timeouts on non-interactive flows. - Firefox: same API; redirect host is derived from the add-on id under
extensions.allizom.org. - Safari: supported in recent versions; on older ones, use a tab-based flow with a redirect your content script or containing app can capture.
Verification
- Run the flow and confirm the authorisation URL contains
code_challengeandcode_challenge_method=S256. - Inspect the token request in the worker’s Network panel:
code_verifierpresent, noclient_secret. - Replay the same code with the same verifier: the provider returns
invalid_grant. - Stop the worker while the sign-in window is open, complete sign-in, and confirm the flow still succeeds from
storage.session.
FAQ
Does Google support PKCE for extension clients?
Yes, Google’s OAuth endpoints support PKCE. Some Google client types still expect a secret; use a client type intended for public clients where available.
Can I use PKCE with getAuthToken?
No. getAuthToken manages Google tokens internally; PKCE applies to launchWebAuthFlow.
Is PKCE enough without state?
No. PKCE protects code redemption; state protects against forged authorisation responses. Use both.
Related
- Implementing OAuth2 with launchWebAuthFlow — the flow’s basics.
- Fixing OAuth redirect URI mismatches — the most common failure.
- Refreshing and storing access tokens securely — what to do with the tokens.
- Identity and OAuth authentication — the parent topic.