Cookie Stores in Incognito and Firefox Containers
Target the right cookie store from an MV3 extension: storeId for incognito, spanning versus split mode, Firefox container stores, and mapping a tab to its store with getAllCookieStores.
Table of Contents
The user is signed in to the site in an incognito window, your popup says “signed out”, and the cookie your extension writes never shows up in that window. Or, in Firefox, the user keeps a “Work” container and a “Personal” container signed in to different accounts, and your extension reads one account’s session while the user is looking at the other. In both cases the API is answering from the wrong cookie store, because every cookie call has an implicit target and the default is rarely the one the user is looking at. This guide is part of cookies and webRequest observation.
Why there is more than one store
Browsers keep separate cookie jars for contexts that must not share identity. Chrome has two: the default store (id "0") and the incognito store (id "1"), which exists only while an incognito window is open. Firefox has "firefox-default", "firefox-private", and one "firefox-container-N" store per container. Every chrome.cookies call accepts a storeId; when you omit it, the call targets the store associated with the calling context. For a service worker in "spanning" incognito mode — the default — that is always the default store, no matter which window the user is in. The extension is not confused; it is asking the wrong jar.
Step-by-step: always target the user’s store
1. Decide your incognito mode deliberately
1{
2 "incognito": "spanning" // one worker; sees all stores via storeId (default)
3 // "incognito": "split" // a second worker instance runs inside incognito
4 // "incognito": "not_allowed" // never runs in incognito
5}
Execution context: the manifest. With "spanning" a single service worker handles events from both normal and incognito windows and must pass storeId explicitly. With "split" Chrome starts a separate worker instance for incognito whose default store is the incognito store, at the cost of two copies of in-memory state. Firefox does not support "split" and treats it as "spanning". The user must still allow the extension in incognito for any of this to apply. The trade-offs are covered in extensions in incognito: split versus spanning.
2. Map tabs to stores with getAllCookieStores
Each store lists the tab ids that use it, which is the bridge from “the tab the user is looking at” to “the storeId to pass”.
1export async function storeIdForTab(tabId) {
2 const stores = await chrome.cookies.getAllCookieStores();
3 const store = stores.find((s) => s.tabIds.includes(tabId));
4 return store?.id ?? "0";
5}
Execution context: the service worker. The incognito store appears only if the extension is allowed in incognito and an incognito window is currently open. Firefox’s tab.cookieStoreId property gives the same answer directly from browser.tabs.get, without a lookup; Chrome’s tab object has no such field.
3. Pass the storeId on every call
1export async function sessionForTab(tabId) {
2 const storeId = await storeIdForTab(tabId);
3 return chrome.cookies.get({
4 url: "https://app.example.com/",
5 name: "session_id",
6 storeId,
7 });
8}
9
10// From the popup:
11const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
12const session = await chrome.runtime.sendMessage({ type: "session", tabId: tab.id });
Execution context: the service worker for the cookie call; the popup for the active-tab query. The popup belongs to a specific window, so currentWindow: true resolves to the right tab even when several windows are open. Writes and removals need the same storeId — writing without it from a spanning worker plants the cookie in the default store, invisible to the incognito tab.
4. Attribute onChanged events to a store
Every change event carries the cookie’s storeId. A badge that reflects sign-in state must update only the tabs that use that store.
1chrome.cookies.onChanged.addListener(async ({ cookie, removed }) => {
2 if (cookie.name !== "session_id") return;
3 const stores = await chrome.cookies.getAllCookieStores();
4 const tabIds = stores.find((s) => s.id === cookie.storeId)?.tabIds ?? [];
5 for (const tabId of tabIds) {
6 chrome.action.setBadgeText({ tabId, text: removed ? "" : "✓" });
7 }
8});
Execution context: the service worker, top level. Per-tab badge text is cleared when the tab navigates, so pair this with a refresh on tabs.onUpdated. Events from the incognito store are only delivered if the extension is allowed in incognito.
5. Handle Firefox containers explicitly
1// Firefox only: list containers and their stores
2if (globalThis.browser?.contextualIdentities) {
3 const identities = await browser.contextualIdentities.query({});
4 for (const { name, cookieStoreId } of identities) {
5 const jar = await browser.cookies.getAll({ domain: "example.com", storeId: cookieStoreId });
6 console.log(name, jar.length);
7 }
8}
Execution context: the Firefox background context. contextualIdentities needs its own permission ("contextualIdentities") and exists only when containers are enabled. Chrome and Safari have no equivalent; feature-detect rather than branching on the user agent.
6. Keep per-store state apart in your own storage
The cookie API is only half the problem. Any state your extension derives from cookies — a cached profile, a sync cursor, an unread count — must be keyed by store too, or the incognito session’s data leaks into the normal window’s popup.
1const keyFor = (storeId, name) => `${name}:${storeId}`;
2
3export async function cacheProfile(storeId, profile) {
4 const area = storeId === "0" || storeId === "firefox-default"
5 ? chrome.storage.local // survives restarts
6 : chrome.storage.session; // incognito and containers: memory only
7 await area.set({ [keyFor(storeId, "profile")]: profile });
8}
Execution context: the service worker. Writing incognito-derived data to chrome.storage.local persists it to disk after the private window closes, which contradicts what the user expects from incognito and is a review risk. chrome.storage.session is cleared on browser exit and never touches disk in Chrome. Firefox containers are persistent identities, so you may choose local for them deliberately — but make it a choice, not an accident.
When the last incognito window closes, Chrome deletes store "1" and every cookie in it. Listen for chrome.windows.onRemoved and, if getAllCookieStores no longer lists the incognito store, drop every cached entry keyed to it so stale data cannot reappear the next time a private window opens.
Cross-browser variation
- Chrome / Edge: store ids
"0"and"1";"split"mode gives incognito its own worker.getAllCookieStoresomits the incognito store when no incognito window is open. - Firefox: string ids, containers, and
tab.cookieStoreIdon every tab."split"is not supported. Private browsing access must be granted by the user per extension. - Safari: private browsing uses a separate store but extensions rarely see it;
getAllCookieStorestypically returns only the default store, and private windows run extensions only when the user enables them.
Verification
- Allow the extension in incognito from
chrome://extensions→ Details. - Sign in to the site in a normal window and sign out in an incognito window (or the reverse).
- Open the popup in each window. Each must report the state of its own window.
- In the service worker console, run
await chrome.cookies.getAllCookieStores()and confirm two stores, each listing the expected tab ids. - In Firefox, repeat with two containers and confirm each container tab reports its own session.
FAQ
Why does getAllCookieStores only return one store?
Either no incognito window is open, or the extension is not allowed in incognito. The incognito store does not exist until a private window does, and it disappears when the last one closes — along with every cookie in it.
Should I use split mode to avoid passing storeId?
Only if your extension’s in-memory state must also be isolated between normal and incognito. Split mode doubles worker instances and makes shared state harder; spanning plus an explicit storeId is simpler for most cookie features.
Can I copy a cookie from the normal store into incognito?
Technically yes — read it from "0" and write it to "1". Doing so defeats the user’s reason for opening incognito, and store reviewers treat it as a privacy violation. Do not.
Related
- Reacting to cookie changes — the event handler this guide extends with stores.
- Partitioned cookies and CHIPS in extensions — partitions inside each store.
- Extensions in incognito: split versus spanning — choosing the manifest mode.
- Cookies and webRequest observation — the parent topic.