Handling Multiple Accounts in an Extension

Support several signed-in accounts in an MV3 extension: per-account token storage, an active-account switcher, prompt=select_account, per-site account mapping, and separating Chrome profiles from service accounts.

Published October 2, 2026 Updated October 2, 2026 8 min read
Table of Contents

The user has a work account and a personal account with your service, and wants the extension to use the right one: the work account on the company wiki, the personal one everywhere else, or simply a switcher in the popup. The first implementation stores “the token” in one key, so signing in to the second account signs out of the first. The identity provider silently reuses whichever account its cookie remembers. And the Chrome profile’s Google account, which the extension can read, turns out to be unrelated to either. Supporting multiple accounts is mostly a data-model change, plus a few OAuth parameters. This guide covers both. It belongs to identity and OAuth authentication.

Three different “accounts”

Three identities are easy to confuse. The browser profile account — the Google account signed into Chrome — is what getProfileUserInfo and getAuthToken use; a user with two Chrome profiles has two separate extension instances with separate storage, and nothing to reconcile. The identity provider session — a cookie at the provider remembering who last signed in — decides which account launchWebAuthFlow returns unless you ask the provider to show its account chooser. The service accounts are the accounts on your backend, each with its own tokens, which is what a multi-account extension actually manages. The design that works keeps a map of service accounts keyed by a stable user id from the provider, one of which is active, with tokens stored per account.

Accounts the extension deals withThe Chrome profile account, the identity provider's session cookie, and the service accounts the extension stores tokens for, with which API each relates to.Chrome profile accountgetProfileUserInfoone per profileProvider sessioncookie at the IdPdecides default accountService accountstokens per user idwhat you storeActive accountused for API callsuser-selectable
Only the bottom layer is yours to manage; the others are inputs.

Step-by-step: a multi-account model

1. Store tokens per account

 1// accounts.js
 2// storage.local:   accounts: { [sub]: { email, name, addedAt } }, activeAccount: sub, refresh:<sub>: token
 3// storage.session: access:<sub>: { token, exp }
 4
 5export async function addAccount(tokens, profile) {
 6  const { accounts = {} } = await chrome.storage.local.get("accounts");
 7  accounts[profile.sub] = { email: profile.email, name: profile.name, addedAt: Date.now() };
 8  await chrome.storage.local.set({ accounts, [`refresh:${profile.sub}`]: tokens.refresh_token, activeAccount: profile.sub });
 9  await chrome.storage.session.set({ [`access:${profile.sub}`]: { token: tokens.access_token, exp: Date.now() + tokens.expires_in * 1000 - 30_000 } });
10}

Execution context: the service worker. The key is the provider’s stable subject identifier (sub from the ID token), not the email, which users can change. Separate keys per account mean adding, refreshing or removing one never touches another. Restrict chrome.storage.local to trusted contexts if it holds refresh tokens.

2. Ask the provider to show its account chooser

1const url = new URL("https://accounts.google.com/o/oauth2/v2/auth");
2url.search = new URLSearchParams({
3  client_id: CLIENT_ID, response_type: "code", redirect_uri: chrome.identity.getRedirectURL(),
4  scope: "openid email profile", code_challenge: challenge, code_challenge_method: "S256",
5  prompt: "select_account",                    // always show the chooser when adding an account
6  state,
7});

Execution context: the service worker. Without prompt=select_account, a provider with a remembered session redirects straight back with the same account the user already added — the most common “I can’t add my second account” bug. Most OpenID Connect providers support the parameter; some use prompt=login instead. For re-authenticating a known account, use login_hint with that account’s email instead.

Adding a second accountThe user clicks Add account; the worker opens the provider with prompt=select_account; the user picks the work account; the worker exchanges the code, reads the sub from the ID token, stores tokens under that sub and makes it active.PopupService workerIdentity provideradd accountauthorize (prompt=select_account)user picks work…codeexchange; read sub from id_tokenaccounts updated (2)
The chooser prompt is what lets a second account in.

3. Resolve the account for each API call

1export async function tokenFor(sub) {
2  const target = sub ?? (await chrome.storage.local.get("activeAccount")).activeAccount;
3  if (!target) throw Object.assign(new Error("no account"), { code: "signed-out" });
4  const { [`access:${target}`]: access } = await chrome.storage.session.get(`access:${target}`);
5  if (access && access.exp > Date.now()) return { sub: target, token: access.token };
6  return { sub: target, token: await refreshFor(target) };
7}

Execution context: the service worker. Every API call goes through tokenFor, optionally with an explicit account. The per-account refresh function must coalesce concurrent refreshes per account — a shared promise per sub — for the same reason as in the single-account case.

4. Map sites to accounts

1export async function accountForUrl(url) {
2  const { siteAccounts = {}, activeAccount } = await chrome.storage.local.get(["siteAccounts", "activeAccount"]);
3  const host = new URL(url).hostname;
4  const match = Object.keys(siteAccounts).filter((h) => host === h || host.endsWith(`.${h}`)).sort((a, b) => b.length - a.length)[0];
5  return match ? siteAccounts[match] : activeAccount;
6}

Execution context: the service worker, called when a content script asks for something on behalf of a page. Users with work and personal accounts often want the account chosen by context — the work account on wiki.company.example, the personal one elsewhere. The longest matching host wins, so docs.company.example can override company.example. Let users set the mapping from the popup (“Use this account on this site”).

Choosing the account for a requestHow the extension picks an account for popup actions, site-specific content script requests, background sync and explicit user choice.Request fromAccount usedSourcePopup actionActive accountSwitcherContent script on a siteSite mapping, else activesiteAccountsBackground syncEach account in turnaccounts mapUser picks explicitlyThat accountArgument
Explicit choice beats site mapping, which beats the active account.

5. Build an account switcher

1// popup.js
2const { accounts = {}, activeAccount } = await chrome.storage.local.get(["accounts", "activeAccount"]);
3for (const [sub, a] of Object.entries(accounts)) {
4  const btn = document.createElement("button");
5  btn.textContent = a.email;
6  btn.setAttribute("aria-pressed", String(sub === activeAccount));
7  btn.addEventListener("click", () => chrome.storage.local.set({ activeAccount: sub }));
8  list.append(btn);
9}

Execution context: the popup. Switching is just a storage write; every context that cares listens to storage.onChanged for activeAccount. Show the active account prominently in every surface that acts on the user’s behalf, so nobody posts from the wrong account.

6. Sync data per account

1chrome.alarms.onAlarm.addListener(async ({ name }) => {
2  if (name !== "sync") return;
3  const { accounts = {} } = await chrome.storage.local.get("accounts");
4  for (const sub of Object.keys(accounts)) {
5    try { await syncAccount(sub); } catch (err) { await recordSyncError(sub, err); }
6  }
7});

Execution context: the service worker. Cached data must be keyed by account too — items:<sub> rather than items — or switching accounts shows the wrong data. Sync each account independently so one account’s expired session does not stop the others.

7. Remove one account cleanly

1export async function removeAccount(sub) {
2  const { accounts = {}, activeAccount, siteAccounts = {} } = await chrome.storage.local.get(["accounts", "activeAccount", "siteAccounts"]);
3  delete accounts[sub];
4  for (const h of Object.keys(siteAccounts)) if (siteAccounts[h] === sub) delete siteAccounts[h];
5  await chrome.storage.local.remove([`refresh:${sub}`, `items:${sub}`]);
6  await chrome.storage.session.remove(`access:${sub}`);
7  await chrome.storage.local.set({ accounts, siteAccounts, activeAccount: activeAccount === sub ? Object.keys(accounts)[0] ?? null : activeAccount });
8}

Execution context: the service worker. Removing an account must remove its tokens, cached data and site mappings, and pick a new active account if needed. Revoke its refresh token on the server too, as in signing users out and revoking tokens.

Common mistakes

  • One token key. Adding a second account signs out the first.
  • No account chooser prompt. The provider returns the same account again.
  • Keying accounts by email. Emails change; use the provider’s sub.
  • Shared caches across accounts. Switching shows the other account’s data.
  • Confusing the Chrome profile with service accounts. They are unrelated.

Cross-browser variation

  • Chrome / Edge: launchWebAuthFlow supports account choosers via provider parameters; getAuthToken is tied to the browser profile and does not support arbitrary multiple accounts.
  • Firefox: same launchWebAuthFlow approach; containers can hold different provider sessions, which may affect which account the provider remembers.
  • Safari: same approach where launchWebAuthFlow is available; otherwise per-account tab-based sign-in.

Verification

  1. Add two accounts and confirm both appear with separate refresh:<sub> keys.
  2. Switch accounts and confirm API calls use the new account’s token and the UI shows its data.
  3. Map a site to the second account and confirm content-script requests on that site use it.
  4. Remove one account and confirm its tokens, data and mappings are gone and the other still works.

FAQ

Can getAuthToken handle two Google accounts?

Not reliably. Use launchWebAuthFlow with the account chooser for multi-account Google sign-in.

Should each account have its own options?

Settings about the extension’s behaviour are usually global; settings about the account’s data — default folder, sync scope — belong per account.

How many accounts should I support?

Two or three covers most users. Design the data model for any number; design the UI for a few.

How do I stop actions from going to the wrong account?

Show the active account in every surface that writes data, and for destructive or public actions — sharing, posting, deleting — confirm with the account name in the button label (“Share as ana@work.example”). It costs one word and prevents the most embarrassing multi-account mistakes.

Other Core APIs & Cross-Browser Data Management Resources