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.

Published September 18, 2026 Updated September 18, 2026 8 min read
Table of Contents

Sign-out in an extension is the operation most often left half-finished. Deleting your own copy of the token does nothing about the one Chrome has cached, and neither step tells the provider that the grant should end — so the next “sign in” returns the same account instantly, the user concludes the button is broken, and a support thread begins. Doing it properly is three distinct operations. This guide is part of identity and OAuth authentication.

Three places a session lives

What a signed-in extension is actually holdingFour layers of session state: your own stored data, the browser's identity token cache, the provider's grant, and the browser profile's signed-in account.Your extension's stateprofile, cached data, refresh tokenyou control this entirelyBrowser identity token cachechrome.identity's own storesurvives your deletionProvider grantthe OAuth authorisation itselfonly a revoke endpoint clears itBrowser profile accountthe user's Chrome sign-innever yours to sign out
Sign-out has to address the first three; the fourth is the user's business and must not be touched.

Step-by-step

1. Remove the token from the browser’s cache

If you obtained the token with getAuthToken, Chrome cached it and will hand back the identical string on the next call — including after you deleted your own copy.

1async function clearCachedToken() {
2  const token = await chrome.identity.getAuthToken({ interactive: false }).catch(() => null);
3  if (!token?.token) return null;
4  await chrome.identity.removeCachedAuthToken({ token: token.token });
5  return token.token;
6}

Execution context: the service worker. getAuthToken with interactive: false returns the cached token without prompting, which is exactly what you want here — you are asking “what do you have”, not “sign me in”. This API is Chrome-only; the Firefox and Safari path is covered in OAuth in Firefox and Safari extensions.

2. Revoke the grant at the provider

Removing the cache entry stops you using the token. Revocation is what tells the provider the authorisation is over, and it is the only step that invalidates a token someone else may have copied.

1async function revokeAtProvider(accessToken) {
2  await fetch("https://oauth2.googleapis.com/revoke", {
3    method: "POST",
4    headers: { "Content-Type": "application/x-www-form-urlencoded" },
5    body: new URLSearchParams({ token: accessToken }),
6  });
7}

Execution context: the service worker. The revoke endpoint returns 200 for an already-invalid token, so a failure here is a network problem rather than a state problem — retry it, but do not block the rest of sign-out on it.

3. Clear everything you stored

Be exhaustive and explicit. A clear() is tempting but takes the user’s settings with it, which is not what sign-out means.

1const SESSION_KEYS = ["accessToken", "refreshToken", "expiresAt", "profile", "syncCursor"];
2
3async function clearLocalSession() {
4  await chrome.storage.local.remove(SESSION_KEYS);
5  await chrome.storage.session.remove(SESSION_KEYS);
6  await caches.delete("api-responses");            // if you cache authenticated responses
7  await chrome.alarms.clear("token-refresh");
8}

Execution context: the service worker. Clearing the refresh alarm matters: a scheduled refresh that fires after sign-out will attempt to use a revoked token and log an error the user never caused. Refresh scheduling is covered in refreshing and storing access tokens securely.

4. Put the three together behind one entry point

 1export async function signOut() {
 2  const token = await clearCachedToken();
 3  await clearLocalSession();
 4  if (token) {
 5    try { await revokeAtProvider(token); }
 6    catch (err) { console.warn("[auth] revoke failed, local sign-out completed", err); }
 7  }
 8  await chrome.action.setBadgeText({ text: "" });
 9  chrome.runtime.sendMessage({ type: "auth:changed", signedIn: false }).catch(() => {});
10}

Execution context: the service worker. Order matters: clear local state before the network call so a failed revoke still leaves the extension signed out. The sendMessage rejection is swallowed deliberately — there may be no popup open to receive it, the case described in fixing “message port closed before response” errors.

5. Handle “switch account” separately

Switching accounts is not sign-out followed by sign-in: the user needs the account chooser, which getAuthToken will skip if a token is cached.

1export async function switchAccount() {
2  await signOut();
3  // Force the chooser rather than silently reusing the profile's default account.
4  return chrome.identity.getAuthToken({ interactive: true, account: undefined });
5}

Execution context: the service worker, and it must be reached from a user gesture — Chrome refuses an interactive identity call otherwise. If the same account comes back without a chooser, the cache clear in step 1 did not run.

A complete sign-outCache removal, local state clearing and provider revocation run in order, with the UI notified last regardless of whether the network step succeeded.removeCachedAuthTokenbrowser cachestorage.remove(keys)your own statealarms.clear('refresh')stop the scheduleronly now talk to the networkPOST /revokebest effortClear the badgevisible feedbackBroadcast auth:changedpopup and options
Local state is cleared before the network call so an offline sign-out still leaves the extension signed out.

Sign-out under failure: offline, mid-flight and multi-surface

Sign-out is the operation most likely to be attempted at a bad moment — on a train, while a sync is running, from a popup that closes two hundred milliseconds later. Each of those has a specific failure and a specific defence.

Offline. The revoke call is the only network step, and it is deliberately last. If it fails, the local sign-out has already completed and the user is signed out from their perspective. Queue the token for a later revoke rather than dropping it:

1async function queueRevocation(token) {
2  const { pendingRevokes = [] } = await chrome.storage.local.get("pendingRevokes");
3  pendingRevokes.push({ token, at: Date.now() });
4  await chrome.storage.local.set({ pendingRevokes: pendingRevokes.slice(-20) });
5  await chrome.alarms.create("revoke-retry", { delayInMinutes: 5 });
6}

Execution context: the service worker. Keeping a revoked-but-unrevocable token on disk is an uncomfortable trade, which is why the list is capped and why entries older than a few days should simply be discarded — the token will have expired by then anyway.

Mid-flight requests. A sync started before sign-out will continue and fail with a 401, producing an error report for something the user caused deliberately. Guard the request path with a generation counter that sign-out increments:

1let authGeneration = 0;
2export const currentGeneration = () => authGeneration;
3export function invalidateAuth() { authGeneration++; }
4
5async function authedFetch(url, gen) {
6  const res = await fetch(url, { headers: await authHeaders() });
7  if (gen !== authGeneration) throw new AbortedBySignOut();
8  return res;
9}

Execution context: the service worker. The counter lives in memory and resets on eviction, which is acceptable: an evicted worker has no in-flight requests to invalidate.

Multiple surfaces. A popup, an options page and a side panel may all be open. The broadcast in step 4 handles the ones that are listening; the ones that are not — because they were opened before the listener existed, or are mid-render — need to re-check on focus.

1document.addEventListener("visibilitychange", async () => {
2  if (document.visibilityState !== "visible") return;
3  const { accessToken } = await chrome.storage.session.get("accessToken");
4  if (!accessToken) renderSignedOut();
5});

Execution context: any extension page. Re-reading storage on focus is cheap and covers every case the broadcast misses, including a sign-out performed on another device and synced across.

Sign-out failure modes and their defencesFour ways sign-out can go wrong — offline revoke, in-flight requests, stale surfaces and a pending refresh alarm — with the state each one leaves behind and the defence.FailureWhat is left behindDefenceRevoke fails (offline)Live token at the providerQueue + retry alarmSync already in flight401 error reportGeneration counterOptions page still openSigned-in UIRe-check on visibilitychangeRefresh alarm pendingRefresh attempt on a dead tokenalarms.clear in step 3
In every row the local sign-out has already succeeded; what is being defended is the tidiness afterwards.

Cross-browser variation

  • Chrome / Edge: chrome.identity.removeCachedAuthToken and clearAllCachedAuthTokens are Chrome-specific. clearAllCachedAuthTokens is the blunt version — useful in a “reset extension” path, too broad for a normal sign-out if you hold tokens for more than one scope.
  • Firefox: there is no getAuthToken, so there is no browser cache to clear. Sign-out is steps 2 and 3 only, against tokens you obtained with launchWebAuthFlow and stored yourself.
  • Safari: same as Firefox in shape, with the additional wrinkle that launchWebAuthFlow may reuse a live web session in Safari’s cookie store — offer the user a link to sign out at the provider’s site if a truly clean state is required.
  • All three: never call anything that signs the user out of the browser profile. That is not within an extension’s remit and will be treated as a policy violation.

Verification

  1. Sign out, then confirm the cache really is empty:
1await chrome.identity.getAuthToken({ interactive: false });
2// rejects: "OAuth2 not granted or revoked."

Execution context: the service worker console. A resolved token here means removeCachedAuthToken was passed the wrong string — it must be the exact token, not the wrapper object.

  1. Check the provider’s account page — the extension should no longer appear in the list of apps with access.
  2. Confirm no scheduled refresh remains with await chrome.alarms.getAll().
  3. Sign in again and confirm the account chooser appears rather than an instant silent sign-in.

FAQ

Is clearAllCachedAuthTokens enough on its own?

It clears Chrome’s cache for every token your extension holds, but it still does not revoke anything at the provider, and it does not touch your stored refresh token. It is a bigger hammer, not a complete sign-out.

Should sign-out delete the user’s settings?

No. Sign out of the account; keep preferences. If the user wants a clean slate, offer a separate, clearly labelled reset that removes everything — and confirm it, because it cannot be undone.

What if revocation fails because the device is offline?

Complete the local sign-out and record the token in a small pending-revocation list. Retry on the next successful network call. Never leave the user signed in because a revoke could not be delivered.

Other Core APIs & Cross-Browser Data Management Resources