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.
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.
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.
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”).
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:
launchWebAuthFlowsupports account choosers via provider parameters;getAuthTokenis tied to the browser profile and does not support arbitrary multiple accounts. - Firefox: same
launchWebAuthFlowapproach; containers can hold different provider sessions, which may affect which account the provider remembers. - Safari: same approach where
launchWebAuthFlowis available; otherwise per-account tab-based sign-in.
Verification
- Add two accounts and confirm both appear with separate
refresh:<sub>keys. - Switch accounts and confirm API calls use the new account’s token and the UI shows its data.
- Map a site to the second account and confirm content-script requests on that site use it.
- 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.
Related
- Getting the signed-in user’s profile info — the browser profile account.
- Using PKCE with launchWebAuthFlow — the sign-in each account goes through.
- Per-site settings and overrides — the site mapping pattern in general.
- Identity and OAuth authentication — the parent topic.