Reacting to Cookie Changes

Detect sign-in and sign-out from chrome.cookies.onChanged without double-firing: filter by cause, debounce overwrite bursts, persist the last value and avoid loops from your own writes.

Published October 2, 2026 Updated October 2, 2026 8 min read
Table of Contents

You want the extension to notice when the user signs in to or out of a site — to refresh the popup, update the badge, or start a sync. chrome.cookies.onChanged looks like the obvious hook, and the first version usually works in a quick test. Then the bug reports arrive: the sync runs twice per login, the badge flickers to “signed out” for a moment on every page load, and after the browser has been idle for a while the extension misses a sign-out completely. All three come from treating the event stream as a list of state changes when it is really a list of store operations. This guide sits under cookies and webRequest observation.

Why one login produces several events

A site that refreshes its session cookie does not “change” it — the network stack overwrites it. Overwrites are reported as two events: a removal of the old cookie with cause: "overwrite", then an addition of the new one with cause: "explicit". A login flow that sets a short-lived pre-auth cookie, redirects, and then sets the real session can produce five or six events in a hundred milliseconds. Meanwhile the listener lives in a service worker that may be cold: the first event of a burst wakes it, the rest queue behind module evaluation, and any in-memory “last value” the previous worker held is gone. A handler that reacts per event, using memory for comparison, sees noise as signal.

Events from a single session refreshThe site's response sets session_id over an existing value; the cookie store emits a removal with cause overwrite and then an addition with cause explicit, both delivered to the service worker.Site responseCookie storeService workerSet-Cookie: session_id=newreplace old valueremoved:true, cause:overwriteold valueremoved:false, cause:explicitnew valueone state chang…
One logical change, two events — and the first one says the cookie was removed.

Step-by-step: a state machine instead of an event handler

1. Register the listener at the top level and filter immediately

 1// sw.js
 2const WATCH = { name: "session_id", domainSuffix: "example.com" };
 3
 4chrome.cookies.onChanged.addListener((info) => {
 5  const { cookie, removed, cause } = info;
 6  if (cookie.name !== WATCH.name) return;
 7  if (!cookie.domain.replace(/^\./, "").endsWith(WATCH.domainSuffix)) return;
 8  if (removed && cause === "overwrite") return;    // the replacement event follows
 9  scheduleReconcile();
10});

Execution context: the service worker, at the top level so the listener exists when an event wakes an evicted worker. The event has no filter argument — every cookie write in every visible store reaches this function, so return early and cheaply. Firefox delivers the same shape; Safari may report causes less precisely, which is one more reason not to trust a single event.

2. Collapse bursts with a short debounce

Rather than acting on each event, schedule one reconciliation after the burst settles.

1let reconcileTimer = null;
2
3function scheduleReconcile() {
4  clearTimeout(reconcileTimer);
5  reconcileTimer = setTimeout(reconcile, 400);
6}

Execution context: the service worker. A 400 ms setTimeout is safe here because pending cookie events keep the worker alive well past it; chrome.alarms would be wrong for a sub-second delay, since the minimum alarm period is far longer. If the worker is evicted before the timer fires, the next startup reconciliation in step 4 covers it.

3. Reconcile against the store, not against the event

The event tells you something changed. The store tells you what is true now.

 1async function reconcile() {
 2  const now = await chrome.cookies.get({
 3    url: "https://app.example.com/",
 4    name: WATCH.name,
 5  });
 6  const state = now ? "signed-in" : "signed-out";
 7  const { authState: previous } = await chrome.storage.session.get("authState");
 8  if (state === previous) return;                   // noise: nothing really changed
 9  await chrome.storage.session.set({ authState: state });
10  onAuthStateChanged(previous, state);
11}

Execution context: the service worker. Persisting the last state in chrome.storage.session rather than a module variable means a freshly woken worker compares against reality, not against undefined. The comparison is what removes the double-fire: two events, one read, one transition.

From noisy events to one transitionFiltered cookie events reset a debounce timer; when it fires, the worker reads the current cookie, compares with the stored previous state and emits a transition only if they differ.onChangedmany per loginFiltername, domain, skip overwriteDebounce400 msone reconcile per burstcookies.getcurrent truthComparestorage.session authStateTransitiononly when different
The event stream is a trigger; the comparison with persisted state is the source of truth.

4. Reconcile on startup to catch what you missed

Events that fired while the extension was disabled, during an update, or before the browser restarted are never replayed.

1chrome.runtime.onStartup.addListener(reconcile);
2chrome.runtime.onInstalled.addListener(reconcile);

Execution context: the service worker, top level. chrome.storage.session is empty after a browser restart, so the first reconcile always emits a transition from undefined — treat that as initialisation, not as a sign-in, if your handler sends notifications.

5. Avoid loops from your own writes

If the extension itself writes cookies on the watched domain — refreshing a token, setting a preference — those writes fire onChanged too. Mark them so the listener can skip them.

1const ownWrites = new Set();
2
3export async function setOwned(details) {
4  ownWrites.add(`${details.name}@${details.url}`);
5  try { return await chrome.cookies.set(details); }
6  finally { setTimeout(() => ownWrites.delete(`${details.name}@${details.url}`), 1000); }
7}

Execution context: the service worker. The marker lives only for a second and only in memory; that is enough because your own write and its event happen in the same worker lifetime. Check the set inside the listener before scheduling a reconcile. Without this, a handler that “repairs” a cookie on change can rewrite it forever.

6. Tell the rest of the extension

The transition belongs to the whole extension, not just the worker. Write the new state where every context can observe it and let each surface react on its own schedule, rather than messaging each one.

1function onAuthStateChanged(previous, next) {
2  // storage.session is already updated; popups and side panels listening to
3  // storage.onChanged re-render on their own
4  chrome.action.setBadgeText({ text: next === "signed-in" ? "" : "!" });
5  if (previous === "signed-out" && next === "signed-in") startSync();
6  if (next === "signed-out") clearUserCaches();
7}

Execution context: the service worker. Content scripts cannot read chrome.storage.session unless you call setAccessLevel with TRUSTED_AND_UNTRUSTED_CONTEXTS; if they need the state, message them or mirror a non-sensitive flag into chrome.storage.local. The popup re-reads state every time it opens anyway, so it never needs a push.

Resist the temptation to do expensive work directly in the transition. Kick off a sync with an alarm or a queued job so a flapping cookie — one that a misbehaving site clears and re-sets on every navigation — cannot trigger a storm of requests. If you see more than a handful of transitions per minute in your logs, the site is the problem, and a minimum interval between syncs is the defence.

Cross-browser variation

  • Chrome / Edge: every write produces events in the order described, including writes from other extensions. Partitioned cookies fire events carrying a partitionKey; filter them out if you only care about first-party sessions.
  • Firefox: same causes and ordering. Events include the storeId for container cookies, so a sign-in inside a “Work” container fires an event you may need to attribute to that container rather than to the user’s default session.
  • Safari: onChanged is supported but less granular; expirations driven by Intelligent Tracking Prevention can arrive as plain removals, and some overwrites are reported as a single addition. The reconcile-against-the-store approach absorbs these differences without per-browser code.
How each engine reports a session refreshEvent sequences emitted by Chrome, Firefox and Safari when a site overwrites an existing session cookie, and whether the reconcile approach handles each.AspectChromeFirefoxSafariOverwrite eventsremove + addremove + addSometimes add onlycause precisionFive causesFive causesCoarserstoreId on eventDefault or incognitoIncludes containersDefault onlyReconcile handles itYesYesYes
The sequences differ; a handler that reads the store after a debounce does not care.

Verification

  1. Open the service worker console and add a temporary log inside onAuthStateChanged.
  2. Sign in to the site in a normal tab. Expect exactly one line: undefined → signed-in or signed-out → signed-in.
  3. Reload the site several times. The session cookie may be refreshed on each load; expect no further lines.
  4. Sign out. Expect one signed-in → signed-out line.
  5. Stop the worker from chrome://serviceworker-internals, sign in again, and confirm the woken worker still logs exactly one transition — proof that the comparison survives eviction.
1[auth] undefined → signed-in
2[auth] signed-in → signed-out
3[auth] signed-out → signed-in

Execution context: the service worker console. More than one line per real action means a filter is missing; zero lines after an eviction means the listener is not registered at the top level.

FAQ

Can I filter onChanged by domain when registering it?

No. Unlike webRequest, the cookies event takes no filter. Filter inside the listener and return as early as possible; the cost of a string comparison per event is negligible.

Watch for it, but confirm it. A removal can be an overwrite in progress or an expiry that the site will immediately replace. Reading the store after a debounce distinguishes a real sign-out from a refresh.

Does onChanged fire for cookies my host permissions do not cover?

No. Events for domains outside your granted hosts are not delivered — which also means a user restricting site access silently stops your sign-in detection. Pair the listener with a chrome.permissions.contains check in the UI.

Other Core APIs & Cross-Browser Data Management Resources