Extensions in Incognito: Split vs Spanning
Choose the incognito manifest mode for an MV3 extension: what spanning, split and not_allowed change for service workers, storage, tabs, cookies and messaging, and how to detect and explain incognito access.
Table of Contents
A user enables “Allow in Incognito” for your extension and reports that it behaves strangely: settings changed in a private window show up in normal windows, the popup lists tabs from both, and a history-style feature quietly remembers sites visited in incognito. Or the opposite: the extension works in normal windows and does nothing at all in incognito, because the manifest says "not_allowed" and nobody remembers why. The incognito key decides how one extension relates to two very different browsing contexts, and the default is rarely thought about. This guide belongs to host permissions and site access.
Three modes, three architectures
Extensions never run in incognito unless the user explicitly allows it in the extension’s details page; the manifest key only decides what happens after they do. "spanning", the default, keeps a single extension instance: one service worker receives events from normal and incognito windows alike, tells them apart by tab.incognito, and shares one chrome.storage area across both. "split" starts a second, separate instance for incognito: its own service worker, its own in-memory state, its own view of the cookie store, and messages that never cross between the two. "not_allowed" prevents the extension from running in incognito even if the user tries to allow it — the toggle is hidden. Each mode is right for a different kind of extension.
Step-by-step: choose and implement a mode
1. Pick by data model, not by convenience
1// A tool with no browsing data (a colour picker, a calculator): spanning
2{ "incognito": "spanning" }
3
4// A tool that keeps per-session state tied to cookies (a site-specific helper): split
5{ "incognito": "split" }
6
7// A tool whose whole purpose is recording browsing (history, time tracking): not_allowed
8{ "incognito": "not_allowed" }
Execution context: the manifest. Ask what the extension remembers. If it remembers nothing about sites, spanning is simplest. If it must keep incognito activity strictly separate and needs the incognito cookie store as its default, split is cleanest. If recording activity is the product, running in incognito contradicts the user’s intent; disallow it explicitly so nobody enables it by mistake.
2. In spanning mode, branch on tab.incognito for anything that persists
1// sw.js — spanning
2chrome.tabs.onUpdated.addListener(async (tabId, change, tab) => {
3 if (change.status !== "complete" || !tab.url) return;
4 const area = tab.incognito ? chrome.storage.session : chrome.storage.local;
5 const key = tab.incognito ? "recent:incognito" : "recent";
6 const { [key]: recent = [] } = await area.get(key);
7 recent.unshift({ url: tab.url, at: Date.now() });
8 await area.set({ [key]: recent.slice(0, 20) });
9});
Execution context: the single service worker in spanning mode. chrome.storage.local is shared across both contexts and written to disk, so anything derived from incognito browsing must go to chrome.storage.session (memory only, cleared at browser exit) or nowhere. The popup opened from an incognito window should read the incognito key; it can tell where it is from chrome.extension.inIncognitoContext. Forgetting this branch is the most common incognito privacy bug.
3. In split mode, expect two of everything
1// sw.js — split; this file runs twice when incognito is open
2const where = chrome.extension.inIncognitoContext ? "incognito" : "normal";
3console.log(`[sw:${where}] started`);
4
5chrome.runtime.onInstalled.addListener(() => {
6 if (chrome.extension.inIncognitoContext) return; // install work once, in the normal instance
7 chrome.contextMenus.create({ id: "lookup", title: "Look up", contexts: ["selection"] });
8});
Execution context: each of the two service worker instances. Alarms, context menus and dynamic rules created by one instance are visible to both — they belong to the extension, not to the instance — so creating them in both instances produces duplicates or errors. Guard install-time setup with inIncognitoContext. Messages sent with chrome.runtime.sendMessage from an incognito page reach only the incognito worker, which is the point of split mode, but also means a popup in incognito cannot ask the normal worker for data.
4. Detect whether incognito is allowed and explain it
1// options.js
2const allowed = await chrome.extension.isAllowedIncognitoAccess();
3const hint = document.querySelector("#incognito-hint");
4hint.hidden = allowed;
5hint.textContent = "To use Readable in Incognito, open chrome://extensions, choose Details, " +
6 "and turn on “Allow in Incognito”.";
Execution context: an extension page. There is no API to request incognito access; the user must enable it. Links to chrome://extensions/?id=<your id> can be opened with chrome.tabs.create from an extension page, though not from web pages. Firefox’s equivalent is browser.extension.isAllowedIncognitoAccess() with the setting called “Run in Private Windows”.
5. Keep incognito data out of sync and analytics
1async function recordUsage(event, tab) {
2 if (tab?.incognito || chrome.extension.inIncognitoContext) return; // never from private browsing
3 await sendAnalytics(event);
4}
5
6async function saveSetting(key, value) {
7 // settings are not browsing data — syncing them from incognito is acceptable
8 await chrome.storage.sync.set({ [key]: value });
9}
Execution context: the service worker or any extension page. Users reasonably expect nothing done in a private window to leave the device or persist after it closes. Settings changes are a grey area — most users expect a preference they change to stick — but URLs, titles, search terms and timing data from incognito should never be sent or stored persistently. Store reviewers check for this when an extension requests incognito-relevant capabilities.
6. Test both modes deliberately
Incognito bugs survive because nobody tests in incognito. Add an end-to-end run that launches the browser with incognito access enabled, opens one normal and one incognito window, performs the same actions in each, and asserts that persistent storage contains nothing from the incognito actions.
1// Playwright: inspect storage after acting in both windows
2const sw = await context.serviceWorkers()[0];
3const local = await sw.evaluate(() => chrome.storage.local.get(null));
4expect(JSON.stringify(local)).not.toContain("incognito-only.example");
Execution context: a Playwright test with the extension loaded. Enabling incognito access for an unpacked extension in automation requires toggling it on the extension’s details page or launching with a prepared profile where it is already enabled. The assertion is crude and effective: a private URL appearing anywhere in persistent storage is a bug.
Cross-browser variation
- Chrome / Edge: all three modes. Split mode starts a second worker only while an incognito window is open; it terminates when the last incognito window closes.
- Firefox: supports
"spanning"and"not_allowed";"split"is treated as spanning. Private windows use thefirefox-privatecookie store, and the user enables access per extension under “Run in Private Windows”. - Safari: private windows run extensions only if the user allows it in Safari’s settings; there is no split mode, and
inIncognitoContextis less consistently reported. Treat Safari as spanning and branch ontab.incognito.
Verification
- Enable “Allow in Incognito” and open one normal and one incognito window.
- In spanning mode, run
chrome.extension.inIncognitoContextin the popup of each window:falseandtruerespectively, with one worker inchrome://serviceworker-internals. - In split mode, confirm two worker entries appear while the incognito window is open and one disappears when it closes.
- Visit a unique URL in incognito, close the window, and confirm it appears nowhere in
await chrome.storage.local.get(null).
FAQ
Does split mode separate chrome.storage.local?
No. Both instances read and write the same chrome.storage.local. Split isolates the worker’s memory, messaging and default cookie store, not persistent storage. Use chrome.storage.session or keyed entries for incognito data.
Can my extension open an incognito window?
Yes, with chrome.windows.create({ incognito: true }), provided the user has allowed incognito access. Without it the call fails.
Is there an event when the user allows incognito?
No. Check isAllowedIncognitoAccess() when relevant UI opens.
Related
- Cookie stores in incognito and Firefox containers — store ids in each mode.
- Chrome storage session vs local — where incognito-derived data belongs.
- Privacy-preserving usage metrics — keeping private windows out of analytics.
- Host permissions and site access — the parent topic.