Partitioned Cookies and CHIPS in Extensions
Find, read and write partitioned (CHIPS) cookies from an MV3 extension: the partitionKey filter, top-level site keys, why getAll omits them, and Firefox's first-party isolation.
Table of Contents
An embedded widget — a chat bubble, a payment iframe, a comment system — stores its session in a third-party cookie. Since browsers began phasing out unpartitioned third-party cookies, widgets have moved to the Partitioned attribute (CHIPS), and your extension’s code that reads widget.example cookies suddenly returns an empty array even though DevTools shows the cookie sitting there. The cookie is real; it lives in a partition your query never asked for. This guide belongs to cookies and webRequest observation.
How partitioning splits one cookie into many
A partitioned cookie is keyed by two things: its own domain and the top-level site the user was on when it was set. widget.example embedded on news.site and on shop.site gets two independent cookie jars, and neither can see the other. The extension API mirrors this exactly. Every cookie object carries an optional partitionKey with a topLevelSite field, and calls that do not mention a partition key see only unpartitioned cookies — the API’s default answer to “which cookies exist?” deliberately excludes every partition, so legacy code keeps behaving as it did before CHIPS existed.
Step-by-step: working with partitions
1. Confirm the cookie is partitioned
Before changing code, prove the diagnosis. In the page’s DevTools, Application → Cookies shows a “Partition Key Site” column for partitioned cookies. From the extension, query with an explicit key:
1const embedded = await chrome.cookies.getAll({
2 domain: "widget.example",
3 partitionKey: { topLevelSite: "https://news.site" },
4});
5console.table(embedded.map(({ name, value, partitionKey }) => ({
6 name, value, site: partitionKey?.topLevelSite,
7})));
Execution context: the service worker console. topLevelSite is a scheme plus registrable domain — https://news.site, never https://www.news.site/page. Passing a full URL or a subdomain returns nothing. Chrome supports partitionKey from version 119; Firefox supports it with the same shape, and Safari does not expose partitions at all.
2. Enumerate partitions when you do not know the top-level site
There is no “list partitions” call, but an empty partitionKey object asks for cookies across every partition.
1export async function allPartitions(domain) {
2 const cookies = await chrome.cookies.getAll({ domain, partitionKey: {} });
3 const bySite = new Map();
4 for (const c of cookies) {
5 const site = c.partitionKey?.topLevelSite ?? "(unpartitioned)";
6 bySite.set(site, [...(bySite.get(site) ?? []), c]);
7 }
8 return bySite;
9}
Execution context: the service worker. The result includes unpartitioned cookies too, so the map’s (unpartitioned) entry is the same set the default query returns. Host permissions still apply: you need access to widget.example itself, and Chrome does not require access to the top-level site to read its partition of another domain’s cookies.
3. Read the partition for the tab the user is looking at
The useful question is usually “what is the widget’s session on the page in front of the user?”
1function topLevelSiteOf(url) {
2 const { protocol, hostname } = new URL(url);
3 // registrable domain approximation — use a PSL library in production
4 const parts = hostname.split(".");
5 return `${protocol}//${parts.slice(-2).join(".")}`;
6}
7
8export async function widgetSessionForTab(tabId) {
9 const tab = await chrome.tabs.get(tabId);
10 return chrome.cookies.get({
11 url: "https://widget.example/",
12 name: "sid",
13 partitionKey: { topLevelSite: topLevelSiteOf(tab.url) },
14 });
15}
Execution context: the service worker. Reading tab.url needs the tabs permission or host access to the tab’s origin. The two-label shortcut fails for sites like example.co.uk; a public-suffix-list library gives the correct registrable domain. Getting the site wrong is indistinguishable from “no cookie”.
4. Write and remove inside a partition
Writes follow the same rule: no key, no partition. A partitioned cookie must also be secure.
1await chrome.cookies.set({
2 url: "https://widget.example/",
3 name: "consent",
4 value: "granted",
5 secure: true,
6 sameSite: "no_restriction",
7 partitionKey: { topLevelSite: "https://news.site" },
8});
9
10await chrome.cookies.remove({
11 url: "https://widget.example/",
12 name: "consent",
13 partitionKey: { topLevelSite: "https://news.site" },
14});
Execution context: the service worker. Omitting secure: true makes Chrome reject the write and resolve null. Removing without the key targets the unpartitioned cookie of the same name, leaving the partitioned one in place — a classic “logout did not work on that one site” bug.
5. Handle partitioned events in onChanged
chrome.cookies.onChanged reports partitioned cookies with their partitionKey. A sign-in detector for the first-party site should ignore them; a widget-aware feature should route them by site.
1chrome.cookies.onChanged.addListener(({ cookie, removed }) => {
2 if (cookie.domain.replace(/^\./, "") !== "widget.example") return;
3 const site = cookie.partitionKey?.topLevelSite;
4 if (!site) return handleFirstParty(cookie, removed);
5 handleEmbedded(site, cookie, removed);
6});
Execution context: the service worker, top level. Firefox reports first-party-isolated cookies with firstPartyDomain rather than partitionKey when isolation is enabled; treat either field as “this belongs to a partition”.
6. Fall back to the frame itself when partitions are unreachable
Sometimes the partition API is the wrong tool. If the feature only needs the widget’s state while the user is looking at it — showing the chat’s unread count, say — a content script injected into the widget’s frame can read that frame’s own document.cookie (for non-httpOnly cookies) or, better, its DOM. That approach works identically in Safari, which hides partitions from the cookies API, and it needs host access only to the widget’s origin.
1// content script registered for https://widget.example/* with all_frames: true
2if (window.top !== window) {
3 const unread = document.querySelector("[data-unread]")?.dataset.unread ?? "0";
4 chrome.runtime.sendMessage({ type: "widget:unread", unread: Number(unread) });
5}
Execution context: a content script in the embedded frame, running in its isolated world with the frame’s DOM and its own partitioned document.cookie. The sender’s tab.id on the receiving side tells the worker which top-level page the frame belongs to, which is exactly the partition you would otherwise have had to compute. Chrome, Firefox and Safari all support all_frames content scripts; Safari requires the user to grant access to the widget’s origin explicitly.
Choose between the two approaches by asking whether you need the cookie when the page is closed. Background features — clearing a widget session from the options page, auditing which sites embed a tracker — need the partition API. Foreground features are usually simpler and more portable from inside the frame.
Cross-browser variation
- Chrome / Edge:
partitionKey.topLevelSitefrom Chrome 119;{}matches all partitions. Newer versions add ahasCrossSiteAncestorfield to the key — leave it unset unless you need to distinguish same-site from cross-site frames. - Firefox: supports
partitionKeyfor Total Cookie Protection partitions, and separatelyfirstPartyDomainfor the older first-party isolation pref. With that pref on, every call must passfirstPartyDomainor it throws. - Safari: partitions third-party storage by default but does not expose partition keys to extensions. Embedded cookies are generally unreachable from
browser.cookies; design features that need them around a content script inside the frame instead.
Verification
- Visit a page that embeds the widget and confirm in DevTools that the cookie shows a partition key site.
- Run
allPartitions("widget.example")in the service worker console and confirm a map entry for that site. - Call
widgetSessionForTabwith the tab’s id and confirm it returns the same value DevTools shows. - Remove the cookie with the partition key and confirm DevTools no longer lists it, while the unpartitioned cookie of the same name — if any — remains.
FAQ
Do I need host permissions for the top-level site too?
No. Access is decided by the cookie’s own domain. Reading widget.example cookies in the news.site partition needs host access to widget.example. You may still need tab access to learn which site the user is on.
Will old code that ignores partitions break?
It keeps working for unpartitioned cookies and silently misses partitioned ones. As more embedded services adopt CHIPS, features built on third-party cookies degrade gradually rather than failing loudly — audit any code that reads cookies for a domain that is usually embedded.
Can an extension un-partition a cookie?
No. Writing an unpartitioned cookie with the same name creates a separate cookie; the embedded service reading from inside its frame still sees its partitioned one.
Related
- Reading and setting cookies with chrome.cookies — the matching rules partitions add to.
- Cookie stores in incognito and Firefox containers — the other way the cookie jar is split.
- Injecting content scripts into dynamic iframes — the fallback when partitions are unreachable.
- Cookies and webRequest observation — the parent topic.