Detecting When Host Permissions Are Granted or Revoked

React to chrome.permissions.onAdded and onRemoved in an MV3 extension: keep content script registrations, cached state and UI in step with grants that change at runtime across Chrome, Firefox and Safari.

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

The options page shows “Enabled on all sites”, but the user switched site access to “on click” an hour ago. A content script registered for a host keeps appearing in getRegisteredContentScripts() after the host was revoked. A feature the user just granted access for does not start until the browser restarts. All three are the same bug: the extension computed something from its permissions once and never recomputed it. Host access changes at runtime, and the browser announces every change. This guide shows how to listen properly. It sits under host permissions and site access.

Where permission changes come from

At least five actors can change what the extension may access while it runs. The extension itself grants optional hosts through chrome.permissions.request and drops them with remove. The user changes Chrome’s site-access setting or toggles hosts in Firefox’s permissions panel. An administrator pushes a policy update that blocks or allows hosts. An extension update changes the manifest. And Safari expires per-site grants after a day. The first three surface as chrome.permissions.onAdded and chrome.permissions.onRemoved events carrying { permissions, origins }; updates surface through runtime.onInstalled; Safari’s expiries often surface as nothing at all. A robust extension therefore listens to the events and re-derives state from chrome.permissions.getAll() at the moments it matters.

Sources of permission change and how they reach youExtension requests, user site-access changes and policy updates fire permissions events; extension updates fire onInstalled; Safari daily expiry fires nothing; all feed one recompute function.request / removeyour own codeUser menusite accessPolicy updateadminpermissions.onAdded / onRemovedrecompute()getAll() → derived stateContent scriptsre-registerUIstorage → onChanged
Many sources, one recompute — derived state always comes from getAll().

Step-by-step: one recompute, many triggers

1. Register the listeners at the top level

1// sw.js
2chrome.permissions.onAdded.addListener((delta) => recompute("added", delta));
3chrome.permissions.onRemoved.addListener((delta) => recompute("removed", delta));
4chrome.runtime.onInstalled.addListener(() => recompute("installed"));
5chrome.runtime.onStartup.addListener(() => recompute("startup"));

Execution context: the service worker’s first synchronous pass. A permission change wakes an evicted worker and dispatches only to listeners that exist by the end of that pass. Including onStartup and onInstalled covers changes made while the browser was closed or during an update, which never fire the permissions events.

2. Derive everything from getAll, not from the delta

 1const API_HOST = "https://api.readable.example/*";
 2
 3async function recompute(reason, delta) {
 4  const { permissions = [], origins = [] } = await chrome.permissions.getAll();
 5  const siteOrigins = origins.filter((o) => o !== API_HOST);
 6  const state = {
 7    reason,
 8    at: Date.now(),
 9    allSites: siteOrigins.some((o) => o === "<all_urls>" || o === "https://*/*" || o === "*://*/*"),
10    sites: siteOrigins,
11    canNotify: permissions.includes("notifications"),
12  };
13  await chrome.storage.local.set({ access: state });
14  await syncContentScripts(siteOrigins);
15  console.info("[access]", reason, delta ?? "", state);
16}

Execution context: the service worker. The delta tells you what changed, which is useful for logging; getAll() tells you what is true, which is what decisions should use. Several events can arrive in quick succession — for example, when the user switches from “specific sites” to “on click”, Chrome may fire one onRemoved per origin — and recomputing from the full state each time makes the order irrelevant. Writing the derived state to storage lets every extension page react through storage.onChanged.

A grant flows to the options pageThe user approves an optional host request; the browser fires onAdded in the worker, which recomputes from getAll, re-registers content scripts and writes access state to storage; the open options page re-renders from storage.onChanged.Options pageBrowserService workerstorage.localpermissions.request(origins)onAdded({origins})getAll()syncContentScriptsset({access})storage.onChanged → render
The options page never asks the browser directly — it renders the worker's derived state.

3. Keep dynamic content scripts in step

 1async function syncContentScripts(siteOrigins) {
 2  const id = "auto-mode";
 3  const current = await chrome.scripting.getRegisteredContentScripts({ ids: [id] });
 4  const want = siteOrigins.length > 0;
 5
 6  if (!want && current.length) return chrome.scripting.unregisterContentScripts({ ids: [id] });
 7  if (!want) return;
 8
 9  const spec = { id, matches: siteOrigins, js: ["auto.js"], runAt: "document_idle", persistAcrossSessions: true };
10  if (current.length) await chrome.scripting.updateContentScripts([spec]);
11  else await chrome.scripting.registerContentScripts([spec]);
12}

Execution context: the service worker. updateContentScripts changes matches in place without a gap where the script is unregistered. Registering with matches the extension is not granted does not throw in Chrome — injection is simply skipped — but keeping matches aligned with grants keeps getRegisteredContentScripts() honest, which makes debugging and support far easier. Firefox supports the same calls from version 102; Safari supports registration with fewer options.

4. Render UI from the derived state

 1// options.js
 2async function render() {
 3  const { access } = await chrome.storage.local.get("access");
 4  document.querySelector("#mode").textContent =
 5    access?.allSites ? "Runs on all sites"
 6    : access?.sites?.length ? `Runs on ${access.sites.length} site(s)`
 7    : "Runs when you click the toolbar icon";
 8}
 9chrome.storage.onChanged.addListener((changes, area) => {
10  if (area === "local" && changes.access) render();
11});
12render();

Execution context: the options page (or popup, or side panel). Reading the worker’s derived state rather than calling chrome.permissions.getAll() directly keeps the logic for what counts as “all sites” in one place. The page updates live if it is open when the user changes site access from the browser menu.

5. Re-check before acting where events are unreliable

1export async function ensureAccess(tabUrl) {
2  const origin = new URL(tabUrl).origin;
3  const ok = await chrome.permissions.contains({ origins: [`${origin}/*`] });
4  if (!ok) await recompute("stale-check");          // heal derived state if it drifted
5  return ok;
6}

Execution context: the service worker, called before any background operation on a host. Safari’s per-site grants can lapse without an event; Chrome’s events are reliable but a worker that was terminated mid-recompute can leave stale derived state. A direct contains check at the point of use is cheap and closes both gaps. If the check disagrees with the stored state, recomputing heals it.

Which changes fire which signalWhether optional grants, user site-access changes, policy updates, extension updates and Safari expiries fire permissions events, onInstalled, or nothing, in each engine.ChangeChromeFirefoxSafariOptional request grantedonAddedonAddedonAddedUser restricts accessonRemovedonRemovedOften nonePolicy updateonRemoved/onAddedVaries—Extension updateonInstalledonInstalledonInstalledDaily grant expiry——None
Listen to events for speed and re-check with contains() for certainty.

Common mistakes

  • Caching a grant at install. Reading getAll() once in onInstalled and storing “we have all sites” is the original bug this guide exists to fix. Store the derived state, but recompute it on every event and every startup.
  • Patching state from the delta. Adding the event’s origins to a stored list and removing them on onRemoved drifts the moment two events race or the worker dies mid-update. Always rebuild from getAll().
  • Registering listeners after an await. A worker woken by a permission change dispatches only to listeners that existed at the end of its first synchronous pass. await loadConfig() before addListener means the very event that woke the worker is lost.
  • Treating the API host as a site. If your backend’s origin is in host_permissions, it appears in getAll() alongside website hosts. Filter it out before deciding whether “auto mode” is on, or a user who restricted every site will still see “Runs on 1 site”.
  • Showing an “Allow” button that cannot work. Requesting an origin not covered by host_permissions or optional_host_permissions fails immediately. Derive the button’s visibility from the manifest’s declared patterns, not just from the current grants.

Cross-browser variation

  • Chrome / Edge: both events fire for extension-initiated changes and for site-access changes in the extensions menu. Origins in the event use the same pattern format as the manifest.
  • Firefox: both events fire when the user toggles a host in the add-on’s permissions panel. Firefox may report origins in a normalised form (for example *://example.com/*), so compare with contains rather than string equality.
  • Safari: events fire for requests made through the API but not consistently for grants and expiries made through Safari’s own per-site prompts. Rely on contains at the point of use.

Verification

  1. Open the options page and the service worker console side by side.
  2. Grant an optional host from the options page: the console logs [access] added, the page updates, and getRegisteredContentScripts() shows the new match.
  3. In Chrome’s extensions menu, switch site access to “On click”: the console logs one or more removed entries and the page shows “Runs when you click the toolbar icon”.
  4. Stop the worker, change access again, and confirm the next event wakes it and recomputes.

FAQ

Do activeTab grants fire onAdded?

No. activeTab grants are temporary and per tab; they never appear in getAll() and fire no permissions events. Treat them as part of the click handler’s context.

Can I listen for permission changes from a content script?

chrome.permissions is not available to content scripts. Have them read the derived state from storage, or message the worker.

Why does onRemoved fire several times for one user action?

Chrome may report each withdrawn origin separately. Recomputing from getAll() makes the count irrelevant; debounce the recompute if it is expensive.

Other MV3 Architecture & Extension Lifecycle Resources