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.
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.
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.
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.
Common mistakes
- Caching a grant at install. Reading
getAll()once inonInstalledand 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
originsto a stored list and removing them ononRemoveddrifts the moment two events race or the worker dies mid-update. Always rebuild fromgetAll(). - 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()beforeaddListenermeans 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 ingetAll()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_permissionsoroptional_host_permissionsfails 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 withcontainsrather 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
containsat the point of use.
Verification
- Open the options page and the service worker console side by side.
- Grant an optional host from the options page: the console logs
[access] added, the page updates, andgetRegisteredContentScripts()shows the new match. - In Chrome’s extensions menu, switch site access to “On click”: the console logs one or more
removedentries and the page shows “Runs when you click the toolbar icon”. - 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.
Related
- Handling user-restricted site access — the UX side of a withdrawn host.
- Registering content scripts at runtime — the registration API this guide drives.
- Showing permission status on the options page — presenting the derived state.
- Host permissions and site access — the parent topic.