Implementing OAuth 2.0 with launchWebAuthFlow

A complete MV3 sign-in implementation: popup button, silent startup check, the five ways launchWebAuthFlow rejects, sign-out with revocation, and how to test it offline.

Published August 1, 2026 Updated August 1, 2026 9 min read
Table of Contents

You have the authorization code flow working in the happy path, and then the first real user closes the consent window. The promise rejects with a message you have never seen, your popup shows a spinner forever, and the next click opens a second window because nothing cleared the pending state. Getting launchWebAuthFlow into production is mostly about the branches that are not the happy path. This page belongs to Identity & OAuth Authentication and builds the sign-in surface end to end, from the button the user clicks to the token the API layer consumes.

Root cause: the auth window is not yours

launchWebAuthFlow hands control to the browser. The extension cannot script the window, read its DOM, close it, or observe anything inside it. The only three outcomes it can distinguish are a resolved URL, a rejection, and a promise that never settles because the worker died. Every state machine you build has to be reconstructable from storage, because the code that started the flow may not be the code that finishes it.

Every way a flow attempt can endA launchWebAuthFlow call resolves with a redirect URL, rejects with one of several browser errors, or leaves the caller with no answer at all.How did launchWebAuthFlow settle?ResolvedRedirect URL returnedparse code, state, errorCheck error and state firsta resolved URL can still be a denialRejectedBrowser-level failureclosed, no gesture, bad URLMap to a user-visible reasonretry vs. reconfigureNever settledWorker evicted mid-flowno callback survivesRecover from storage on next startclear the pending marker
Only the first branch is a success — and even it can carry an error parameter from the provider.

Step 1. Put the trigger behind a real gesture

Chrome suppresses the interactive window when the call does not originate from a user action. A message from the popup counts, because the popup itself was opened by a click; a chrome.alarms handler does not.

1// popup.js — the whole sign-in UI is three lines of message passing
2document.getElementById("signin").addEventListener("click", async (event) => {
3  event.target.disabled = true;
4  const result = await chrome.runtime.sendMessage({ type: "AUTH_SIGN_IN" });
5  event.target.disabled = false;
6  render(result);
7});

Execution context: Popup document, an ordinary page with a DOM and no access to the worker’s variables. The listener must be attached from an external file — inline onclick is refused by the extension content security policy in all three engines. Disabling the button for the duration is what prevents the double-window bug; the popup stays open while the auth window is in front of it in Chrome and Firefox, but Safari may close the popup, which is why the worker also guards against concurrent flows.

Step 2. Guard the flow in the worker, not the UI

The popup can close at any moment, so the concurrency guard belongs where the flow actually runs.

 1// sw-auth.js
 2let pendingFlow = null;
 3
 4export function signInOnce(options) {
 5  pendingFlow ??= signIn(options).finally(() => { pendingFlow = null; });
 6  return pendingFlow;
 7}
 8
 9chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
10  if (msg?.type !== "AUTH_SIGN_IN") return false;
11  signInOnce({ interactive: true }).then(
12    () => sendResponse({ ok: true }),
13    (error) => sendResponse({ ok: false, reason: classify(error) }),
14  );
15  return true;
16});

Execution context: Service worker, top-level listener registration. pendingFlow is deliberately module-scoped rather than persisted: if the worker is evicted the flow is gone anyway, so there is nothing left to de-duplicate. Chrome keeps the worker alive while the auth window is open; Firefox behaves the same; Safari does not guarantee it, so a rejection on Safari after an app switch is expected and should be retried rather than reported as a failure.

Step 3. Classify the rejection

The browser rejects with an Error whose message is not part of any specification. Match on substrings, and always keep a default branch — the wording changes between versions and engines.

Rejection wording by engineThe same three failures produce differently worded errors in each browser, which is why the classifier matches on fragments and keeps a default branch.FailureChrome 120+Firefox 121+Safari 17+User closed the windowdid not approvecanceledgeneric cancelSilent call needs consentinteraction requireddiffersgeneric cancelAuth page unreachablecould not be loadednetwork errorgeneric cancelSystem dismissed the sheetN/AN/ALooks like a cancel
No specification governs these strings — treat every match as a heuristic with a fallback.
 1// sw-auth.js
 2export function classify(error) {
 3  const text = String(error?.message ?? error).toLowerCase();
 4
 5  if (text.includes("user did not approve") || text.includes("canceled") || text.includes("cancelled")) {
 6    return { code: "cancelled", retry: true, message: "Sign-in was cancelled." };
 7  }
 8  if (text.includes("user interaction required")) {
 9    return { code: "interaction-required", retry: true, message: "Sign in to continue." };
10  }
11  if (text.includes("authorization page could not be loaded")) {
12    return { code: "network", retry: true, message: "Could not reach the sign-in page." };
13  }
14  if (text.includes("redirect_uri") || text.includes("invalid request")) {
15    return { code: "misconfigured", retry: false, message: "Sign-in is misconfigured." };
16  }
17  return { code: "unknown", retry: true, message: "Sign-in failed. Try again." };
18}

Execution context: Service worker, a pure function with no API calls, which makes it the one part of the auth layer that is trivial to unit test — see mocking Chrome APIs in Jest. Chrome produces “The user did not approve access.” on cancellation and “User interaction required.” for a non-interactive call that needs consent. Firefox uses different wording for both, and Safari frequently reports a generic cancellation when the system dismisses the sheet — which is exactly why retry: true is the default rather than a hard failure.

Step 4. Try silently before showing anything

On worker startup, attempt a non-interactive flow. If the provider still has a session, the user is signed in before they notice; if not, nothing is shown and the popup renders its signed-out state.

Cold start: silent attempt, then user-driven fallbackThe worker tries a non-interactive flow on startup; only a subsequent user click escalates to the interactive window.Worker startidentityPopuplaunchWebAuthFlow({ interactive: false })resolves — session still validor rejects — interaction requiredno window is shownAUTH_SIGN_IN after a clicklaunchWebAuthFlow({ interactive: true })
The silent attempt costs one rejected promise and removes a click for anyone with a live provider session.
 1// sw.js — top level, runs on every cold start
 2chrome.runtime.onStartup.addListener(() => { warmSession(); });
 3chrome.runtime.onInstalled.addListener(() => { warmSession(); });
 4
 5async function warmSession() {
 6  const { refresh } = await chrome.storage.local.get("refresh");
 7  if (!refresh) return;                       // never signed in — do not probe
 8  try {
 9    await signInOnce({ interactive: false });
10  } catch (error) {
11    if (classify(error).code === "interaction-required") return;   // expected
12    console.warn("silent sign-in failed", error);
13  }
14}

Execution context: Service worker, both listeners registered synchronously at the top level so they survive eviction. Checking for a stored refresh token first avoids probing for users who have never signed in, which otherwise produces a rejected promise on every browser launch. onStartup does not fire when the extension is reloaded from the extensions page, which is why onInstalled runs the same routine — a difference that catches people out during development in every engine.

Step 5. Sign out completely

Deleting your copy of the token is half the job. The provider still has a session, so the next sign-in silently succeeds and the user believes sign-out did nothing.

 1// sw-auth.js
 2export async function signOut() {
 3  const { refresh } = await chrome.storage.local.get("refresh");
 4
 5  if (refresh) {
 6    // RFC 7009 revocation — fire and forget, but await so errors are visible in the log.
 7    await fetch("https://auth.example.com/oauth2/revoke", {
 8      method: "POST",
 9      headers: { "Content-Type": "application/x-www-form-urlencoded" },
10      body: new URLSearchParams({ token: refresh, client_id: CLIENT_ID, token_type_hint: "refresh_token" }),
11    }).catch((error) => console.warn("revoke failed", error));
12  }
13
14  await chrome.storage.session.remove("auth");
15  await chrome.storage.local.remove("refresh");
16  await chrome.action.setBadgeText({ text: "" });
17}

Execution context: Service worker. Revocation is a network call that can fail while the user is offline, so local state is cleared regardless — an unrevoked token that no longer exists on this device is a smaller problem than a sign-out button that appears broken. On Chrome you would additionally call chrome.identity.clearAllCachedAuthTokens() if any part of the extension uses getAuthToken; that method does not exist in Firefox or Safari, so guard it with a capability check rather than a browser check, as described in feature detection instead of browser sniffing.

Cross-browser variation

  • Chrome/Edge: the auth window is a separate popup window; closing it rejects with “The user did not approve access.” The worker is kept alive for the duration of the flow. clearAllCachedAuthTokens is available.
  • Firefox: the flow opens in a new tab rather than a window on some platforms, so the user may navigate away instead of closing it — the promise then stays pending until the tab is closed. Give any UI spinner a timeout rather than waiting indefinitely.
  • Safari: the sheet can be dismissed by the system on app switch, producing a cancellation that the user did not perform. Retrying interactively on the next click is the correct handling; showing “you denied access” is not.
  • All engines: interactive: false never renders UI, and a rejection from it is normal operation rather than an error worth logging at warning level.

Verification

  1. Open the popup, click sign in, and close the auth window without consenting. The popup should return to its signed-out state with a retry affordance, not a spinner.
  2. Click sign in twice in quick succession. Exactly one auth window should open — the second click is absorbed by the pendingFlow guard.
  3. In the service worker console, run await chrome.storage.session.get("auth") after a successful sign-in and confirm the token and expiresAt are present.
  4. Restart the browser and watch the worker console: warmSession should log nothing and the popup should render as signed in.
  5. Sign out, then sign in again. The provider should show its consent screen only if it actually revoked the grant — if it signs you straight back in, the revocation endpoint is not being reached.

FAQ

Why does the auth window not open at all?

The call did not originate from a user gesture, or interactive was left at its default of false. A chrome.alarms or onStartup handler cannot open the window in any engine.

Can I use an OAuth client library from npm?

Only the parts that are pure computation. Anything that touches window, document, localStorage or opens a popup itself will fail in the worker. The PKCE helper and the token request are about thirty lines, which is usually less than the adapter code a library needs.

Should the popup or the worker hold the token?

The worker. The popup is destroyed every time it closes, and a token in popup memory would be re-fetched on every open. The popup should ask for the token by message and never cache it.

What happens if the user signs in on two devices?

Nothing special, unless you stored the refresh token in chrome.storage.sync — which you should not. Each installation runs its own flow and holds its own tokens.

Other Core APIs & Cross-Browser Data Management Resources