Cookies & webRequest Observation

Read, write and watch cookies with chrome.cookies, observe network traffic with non-blocking webRequest in MV3, and handle partitioned cookies, incognito stores and auth challenges.

Manifest V3 took blocking webRequest away from almost every extension, and a lot of developers concluded that the whole network layer was gone. It is not. Two capabilities survive intact and are, if anything, more important now: the chrome.cookies API, which reads and writes the browser’s cookie jar directly from the service worker, and the observational half of chrome.webRequest, which still reports every request, redirect, response header and error — it just can no longer stop or rewrite them. Blocking and rewriting moved to declarativeNetRequest rules; watching stayed where it was. This topic sits inside Core APIs & Cross-Browser Data Management and covers the half of the network layer that MV3 left in your hands.

The difficulty is that both APIs were designed for a world of persistent background pages and a single cookie jar. Today the listener lives in a service worker that the browser evicts after thirty idle seconds, the cookie jar is split by partition key, incognito session and Firefox container, and the host permissions that gate both APIs can be withheld by the user at any time. An extension that ignores those facts still works in a quick manual test and then silently misses events, reads the wrong cookie, or wakes the worker a thousand times a minute.

What MV3 kept and what it movedCookie reads and writes and observational webRequest stay available to the service worker; blocking request modification moved to declarativeNetRequest; blocking auth handling survives only through webRequestAuthProvider.chrome.cookiesget, getAll, set, remove, onChangedunchanged in MV3webRequest observersonBeforeRequest … onCompletedread-only listenerswebRequest onAuthRequiredasyncBlocking via webRequestAuthProviderthe one blocking exceptionRequest blocking and rewritingdeclarativeNetRequest rulesmoved out of your code
Observation stayed in your code. Interception moved into rules the browser evaluates without you.

Prerequisites checklist

  • The cookies permission for any chrome.cookies call, plus host permissions for every domain whose cookies you read or write.
  • The webRequest permission and host permissions for every URL you want to observe.
  • The webRequestAuthProvider permission if you answer HTTP authentication challenges.
  • All chrome.cookies.onChanged and chrome.webRequest.* listeners registered synchronously at the top level of the service worker.
  • A decision about incognito: whether your extension runs there at all, and if so which cookie store each call targets.
  • A policy for what you do with observed URLs, because the store listing must disclose any collection of browsing activity.

Manifest registration

 1{
 2  "manifest_version": 3,
 3  "name": "Session Inspector",
 4  "version": "1.4.0",
 5  "background": { "service_worker": "sw.js", "type": "module" },
 6  "permissions": [
 7    "cookies",                 // the cookies API itself — no warning on its own
 8    "webRequest",              // observational listeners only in MV3
 9    "webRequestAuthProvider"   // lets onAuthRequired answer asynchronously
10  ],
11  "host_permissions": [
12    "https://*.example.com/*"  // scopes BOTH cookie access and observed requests
13  ],
14  "incognito": "spanning"      // one worker sees both normal and incognito stores
15}

Execution context: parsed at install and on every update. Chrome shows a “read and change your data on example.com” warning for the host permission, not for cookies or webRequest themselves. Firefox treats host_permissions as optional in MV3, so the same manifest installs with the hosts ungranted until the user opts in. Safari accepts the keys but only reports a subset of request types to webRequest.

The cookies API sees what the browser sees, which is both its power and its trap. A get call needs a URL and a name, and it returns the cookie that would be sent to that URL — after domain matching, path matching and the secure check. That means a cookie set for .example.com with path /app is invisible to a get for https://example.com/, and the call quietly resolves to null rather than telling you why.

 1// sw.js
 2export async function readSession() {
 3  const cookie = await chrome.cookies.get({
 4    url: "https://app.example.com/dashboard",
 5    name: "session_id",
 6  });
 7  return cookie?.value ?? null;
 8}
 9
10export async function writePreference(value) {
11  return chrome.cookies.set({
12    url: "https://app.example.com/",
13    name: "ui_density",
14    value,
15    secure: true,
16    sameSite: "lax",
17    expirationDate: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 180,
18  });
19}

Execution context: the service worker, though the same calls work from the popup, options page or side panel — any extension page with the cookies permission. Content scripts cannot call chrome.cookies at all and must message the worker. Firefox returns native promises from browser.cookies; Chrome has returned promises since 88. Omitting expirationDate creates a session cookie that the browser clears on exit.

The rules for constructing the url parameter, setting domain versus host-only cookies, and why set resolves to null on a silent rejection are covered in reading and setting cookies with chrome.cookies.

How cookies.get resolves a URL to one cookieThe browser takes the URL, filters the store by domain, path and secure flag, then by partition key, and returns the longest-path match or null.url + nameyour queryDomain matchhost-only or .domainPath matchcookie path is a prefixthen the security and partition filtersSecure checkhttps only if securePartition keyunpartitioned by defaultResultlongest path wins, else null
A null result usually means one of the filters excluded the cookie — not that it does not exist.

2. Watching cookies change

chrome.cookies.onChanged fires for every cookie write in every store the extension can see, including writes made by web pages, by other extensions and by your own code. Each event carries a removed flag and a cause, and the combination tells you what actually happened: a cookie being replaced fires a removal with cause overwrite followed by an addition, which is the single most common source of “my handler ran twice” bugs.

1// sw.js — top level, before any await
2chrome.cookies.onChanged.addListener(({ cookie, removed, cause }) => {
3  if (!cookie.domain.endsWith("example.com")) return;
4  if (cookie.name !== "session_id") return;
5  if (removed && cause === "overwrite") return;   // the new value arrives next
6  handleSessionChange(removed ? null : cookie.value, cause);
7});

Execution context: the service worker. The listener wakes an evicted worker, so it must be registered at the top level of the script. Filtering is entirely your job — there is no URL filter argument — and a busy site can generate dozens of events per page load. Firefox fires the same event with the same causes; Safari supports onChanged but does not always distinguish expired from evicted.

Debouncing those bursts, following a login across subdomains, and avoiding feedback loops when your own set triggers the listener are covered in reacting to cookie changes.

onChanged causes and what to do with eachThe five cookie change causes — explicit, overwrite, expired, expired_overwrite and evicted — with whether removed is true and how a typical handler should react.causeremovedMeansHandlerexplicittrue or falseSet or deleted on purposeReactoverwritetrueReplaced by a new valueSkip; the add followsexpiredtrueLifetime endedTreat as signed outexpired_overwritetrueWritten with a past expiryTreat as deletedevictedtrueStore limit reachedLog; rarely user-driven
Ignore the removal half of an overwrite; treat everything else as a real state change.

3. Partitions, incognito and containers

There is no longer one cookie jar. Chrome partitions third-party cookies set with the Partitioned attribute (CHIPS) by top-level site, so the same name on the same embedded domain can hold different values on different sites. Incognito windows use a separate store with id "1". Firefox adds containers, each with its own store such as "firefox-container-2", and first-party isolation when the user enables it. Every one of these is invisible unless you ask for it.

 1// List every cookie for a domain across every store this extension can see.
 2export async function cookiesEverywhere(domain) {
 3  const stores = await chrome.cookies.getAllCookieStores();
 4  const results = [];
 5  for (const store of stores) {
 6    const jar = await chrome.cookies.getAll({ domain, storeId: store.id });
 7    results.push({ storeId: store.id, tabIds: store.tabIds, count: jar.length });
 8  }
 9  return results;
10}

Execution context: the service worker. getAllCookieStores only lists the incognito store if the extension is allowed in incognito and declares "incognito": "spanning"; with "split", the incognito instance of the worker sees only its own store. Partitioned cookies are excluded from getAll unless the filter includes a partitionKey. Firefox returns container stores here; Chrome and Safari never do.

The partitioned case has its own guide, partitioned cookies and CHIPS in extensions, and the store-by-store mechanics are in cookie stores in incognito and Firefox containers.

Which cookie store does a call reach?A decision tree showing how storeId, incognito mode and partition keys determine which cookies a chrome.cookies call can see.What did the call specify?no storeIdCalling context's storedefault store from the workerIncognito missedunless split modestoreId "1"Chrome incognito storeneeds incognito accessFails if not allowedrejects with an errorpartitionKeyPartitioned jarper top-level siteCHIPS cookies onlyunpartitioned excluded
With no storeId the call targets the store of the calling context — usually the default one.

4. Observing requests with webRequest

Non-blocking webRequest listeners still receive the full lifecycle of a request — onBeforeRequest, onBeforeSendHeaders, onSendHeaders, onHeadersReceived, onResponseStarted, onBeforeRedirect, onCompleted and onErrorOccurred — with URLs, methods, initiators, tab ids, status codes and, with the extraHeaders option, headers the browser normally hides. What they cannot do is return a BlockingResponse. A listener registered with ["blocking"] in a store-installed Chrome extension throws at registration.

 1// sw.js — top level
 2const FILTER = { urls: ["https://api.example.com/*"], types: ["xmlhttprequest"] };
 3
 4chrome.webRequest.onCompleted.addListener((details) => {
 5  recordTiming(details.tabId, details.url, details.statusCode, details.timeStamp);
 6}, FILTER);
 7
 8chrome.webRequest.onErrorOccurred.addListener((details) => {
 9  recordFailure(details.tabId, details.url, details.error);
10}, FILTER);

Execution context: the service worker. Each matching request wakes the worker if it is asleep, so a broad filter on a busy page keeps it alive almost permanently and costs real CPU. Keep urls and types as narrow as the feature allows. Firefox still supports blocking listeners in MV3; Safari only reports main-frame and subresource requests for pages the extension has access to, and omits several header fields.

Filtering strategy, the extraHeaders option, correlating requests by requestId, and keeping per-tab data across worker restarts are in observing network requests with webRequest in MV3.

One request through the observational listenersA page fetch passes through onBeforeRequest, onSendHeaders, onHeadersReceived and onCompleted; each event is copied to the service worker, which can read but not change the request.PageNetwork stackService workerfetch(api/items)onBeforeRequestcopy, no waitonSendHeadersonHeadersReceivedstatus, headersresponse bodyonCompletedtiming, fromCache
Every event is a notification after the fact — the network stack never waits for your listener.

5. Answering authentication challenges

HTTP Basic, Digest and proxy authentication are the one place where MV3 kept a blocking webRequest hook. With the webRequestAuthProvider permission, an onAuthRequired listener registered with ["asyncBlocking"] can supply credentials, cancel the challenge, or fall through to the browser’s own dialog. Enterprise proxy extensions and internal-tool helpers depend on this.

 1chrome.webRequest.onAuthRequired.addListener(
 2  (details, callback) => {
 3    if (!details.isProxy) return callback({});          // let the browser prompt
 4    lookupProxyCredentials(details.challenger.host).then((creds) => {
 5      callback(creds ? { authCredentials: creds } : { cancel: true });
 6    });
 7  },
 8  { urls: ["<all_urls>"] },
 9  ["asyncBlocking"]
10);

Execution context: the service worker. The callback must be called exactly once; forgetting it leaves the request hanging until the browser times it out. Firefox accepts the same listener with "blocking" and a returned promise. Safari does not dispatch onAuthRequired to extensions. Loop protection, credential storage and proxy versus server challenges are in handling auth challenges with webRequestAuthProvider.

6. Keeping observations across worker restarts

An observer that counts requests per tab in a Map works perfectly until the page goes quiet for thirty seconds, the worker is evicted, and the next request arrives at a fresh worker with an empty map. The counts reset, the badge flips back to zero, and the user sees a number that depends on how long they paused. The fix is to treat memory as a cache in front of chrome.storage.session, which survives eviction but not a browser restart — exactly the lifetime per-tab network data should have.

 1const counts = new Map();
 2let loaded = chrome.storage.session.get("counts").then(({ counts: saved = {} }) => {
 3  for (const [tabId, n] of Object.entries(saved)) counts.set(Number(tabId), n);
 4});
 5
 6export async function bump(tabId) {
 7  await loaded;
 8  counts.set(tabId, (counts.get(tabId) ?? 0) + 1);
 9  chrome.storage.session.set({ counts: Object.fromEntries(counts) });
10}
11
12chrome.tabs.onRemoved.addListener(async (tabId) => {
13  await loaded;
14  counts.delete(tabId);
15  chrome.storage.session.set({ counts: Object.fromEntries(counts) });
16});

Execution context: the service worker. chrome.storage.session is in-memory, capped at 10 MB in Chrome, and cleared when the browser exits. Firefox supports it from version 115; Safari from 16.4. Writing on every request is acceptable for modest traffic — batch the writes with a short debounce if a page fires hundreds of requests per second.

Cross-cutting concerns: permissions, privacy and review

Both APIs are gated by host permissions, and that is the point at which most production bugs appear. A user who restricts the extension to “on click” in Chrome’s site access menu removes the host permission for every site they have not clicked on, and from then on cookies.get resolves to null and webRequest listeners simply stop firing for those hosts — no error, no event. Check chrome.permissions.contains({ origins }) before treating a missing cookie as a signed-out user, and listen for chrome.permissions.onRemoved to update the UI, as covered under host permissions and site access.

Privacy review is the other cross-cutting concern. Observing request URLs is collecting browsing activity in the eyes of every store’s policy, even if the data never leaves the device. The listing must say what is observed and why, the privacy policy must describe retention, and <all_urls> in host_permissions triggers the in-depth review path described in passing Chrome Web Store review. Prefer narrow host patterns; they are easier to justify and they keep the worker asleep.

Content security policy matters less here than elsewhere — cookies and request metadata are data, not code — but cookie values are attacker-controlled input whenever the site that sets them is compromised or hostile. Never render a cookie value as HTML, and never use a cookie value to build a URL the extension navigates to without validating it.

MV3 constraints box

  • No blocking listeners. "blocking" in extraInfoSpec throws in store-installed Chrome extensions; only onAuthRequired with "asyncBlocking" and webRequestAuthProvider may wait.
  • Listeners wake the worker. Each matching request or cookie change starts an evicted worker; broad filters translate directly into battery and CPU.
  • Thirty-second idle timeout. Per-request bookkeeping held in memory is lost when the worker is evicted mid-page-load; persist to chrome.storage.session.
  • Host permissions gate everything. Without a granted origin, cookies.* resolves empty and webRequest stays silent for that host.
  • No content-script access. Neither API is exposed to content scripts; they must message the worker.
  • Partitioned cookies are opt-in. getAll without partitionKey silently omits CHIPS cookies.

Cross-browser notes

CapabilityChrome / EdgeFirefoxSafari
cookies.get/set/getAll/removeYesYesYes, fewer fields
cookies.onChanged causesAll fiveAll fiveCoarser causes
Partitioned cookies (partitionKey)Yes, CHIPSYes, plus first-party isolationNot exposed
Container cookie storesNofirefox-container-NNo
Observational webRequestYesYesSubset of types and fields
Blocking webRequestPolicy-installed onlyYes in MV3No
onAuthRequired asyncwebRequestAuthProviderblocking + promiseNot dispatched

The single most useful portability rule: write every network observer as a pure function of the details object, and keep the chrome.webRequest wiring in one file per browser target. The pure function can be unit tested without a browser, and the wiring is short enough to vary per engine.

What this section covers

The guides follow the order you will meet the problems. Start with reading and setting cookies with chrome.cookies for the URL and domain rules, then reacting to cookie changes for login detection without double-firing. The two store-shaped problems are partitioned cookies and CHIPS in extensions and cookie stores in incognito and Firefox containers. On the request side, observing network requests with webRequest in MV3 covers filters and per-tab bookkeeping, and handling auth challenges with webRequestAuthProvider covers the one blocking hook MV3 kept.

What is deliberately not here: rewriting headers and blocking requests, which belong to declarativeNetRequest rules; calling your own backend from the extension, which is network requests and backend sync; and the OAuth flows that produce tokens rather than cookies, which are identity and OAuth authentication.