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.

Published October 2, 2026 Updated October 2, 2026 8 min read
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.

Authorisation code flow with PKCEThe extension creates a verifier and challenge, opens the authorisation URL with the challenge via launchWebAuthFlow, receives a code at the redirect URL, and exchanges code plus verifier at the token endpoint.Service workerAuth windowIdentity providerverifier + S256 challengelaunchWebAuthFlow(url + challenge)user signs inredirect ?code&stateresolve redirect URLPOST /token {code, verifier}tokens
The challenge travels first; the verifier only at the end.

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.

OAuth flows for extensionsImplicit flow, authorisation code with client secret, and authorisation code with PKCE compared on security for public clients, refresh token support and current recommendation.FlowSafe for public clientRefresh tokensStatusImplicit (token in fragment)WeakNoDeprecatedCode + client secretSecret is publicYesWrong for extensionsCode + PKCEYesYesRecommended
PKCE is the only one of the three that is both secure and current for extensions.

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.

Diagnosing a failed PKCE flowDecision tree mapping common PKCE failures to causes: a redirect mismatch at the provider, a state mismatch in the extension, invalid_grant at the token endpoint, and a cancelled window.Where did it fail?provider error pageredirect_uri mismatchwrong extension idRegister exact URLper store buildstate mismatchLost or stale stateworker restart / reusestorage.sessionone flow at a timeinvalid_grantVerifier or codereused or mismatchedSame verifier, onceand same redirectpromise rejectedUser closed windowcancellationShow sign-in againno error report
Where it fails tells you what to check.

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 plain challenge method. It offers no protection; use S256.
  • 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: launchWebAuthFlow with chromiumapp.org redirects; interactive: false for 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

  1. Run the flow and confirm the authorisation URL contains code_challenge and code_challenge_method=S256.
  2. Inspect the token request in the worker’s Network panel: code_verifier present, no client_secret.
  3. Replay the same code with the same verifier: the provider returns invalid_grant.
  4. 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.

Other Core APIs & Cross-Browser Data Management Resources