Handling User-Restricted Site Access

Keep an MV3 extension usable when users set Chrome site access to 'on click' or specific sites: detect withdrawn hosts, explain in the popup, request access in context and re-register content scripts.

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

A user writes in: “the extension stopped working on half my sites.” Nothing in your code changed. They opened Chrome’s extensions menu, saw “This extension can read and change site data”, and picked “When you click the extension” — or “On specific sites” with a short list. From that moment, your declared host_permissions are withdrawn for every site not on the list, content scripts silently stop injecting there, and API calls for those hosts return nothing. The browser does not tell the extension in any obvious way. This guide shows how to notice and respond. It sits under host permissions and site access.

How the restriction works inside the browser

Chrome treats the manifest’s host list as a request and the user’s site-access setting as the decision. “On all sites” grants everything requested. “On specific sites” grants the intersection of the request and the user’s list. “On click” grants nothing persistently; instead, clicking the extension’s icon on a site grants that site — for the tab, until navigation — exactly as activeTab would, even if the manifest never declared activeTab. When the setting changes, Chrome fires chrome.permissions.onRemoved or onAdded with the affected origins, chrome.permissions.getAll() returns the reduced list, and the manifest’s content_scripts entries are filtered against the granted hosts before injection. The extension’s own code keeps running exactly as before; it just finds the world smaller.

Effect of each site access settingFor an extension declaring three hosts, which hosts are granted, whether declared content scripts inject automatically, and what a click does under all sites, specific sites and on click.SettingGranted hostsAuto injectionClick on a siteOn all sitesAll declaredEverywhere declaredNormal actionOn specific sitesDeclared ∩ user listOnly listed sitesNormal actionOn clickNone persistentlyNeverGrants that tab
On click turns every automatic feature into a click-triggered one.

Step-by-step: stay useful under restriction

1. Check access instead of assuming it

1// sw.js
2const RESTRICTED = /^(chrome|edge|about|chrome-extension|devtools):|^https:\/\/chromewebstore\.google\.com\//;
3
4export async function accessFor(tab) {
5  if (!tab?.url) return "unknown";                 // no tabs permission and no grant
6  if (RESTRICTED.test(tab.url)) return "restricted-page";
7  const origin = new URL(tab.url).origin;
8  return (await chrome.permissions.contains({ origins: [`${origin}/*`] })) ? "granted" : "withdrawn";
9}

Execution context: the service worker. tab.url itself is only visible with access to the tab or the tabs permission; under “on click” without tabs, the URL is undefined until the user clicks, which is a signal in its own right. Distinguishing browser-internal pages matters because no grant can make them work, so asking the user to allow access there would be misleading.

2. Tell the user on the toolbar icon

A withdrawn host makes automatic features silently absent. A small, consistent signal on the icon turns that into something the user can act on.

1chrome.tabs.onUpdated.addListener(async (tabId, change, tab) => {
2  if (change.status !== "complete") return;
3  const state = await accessFor(tab);
4  await chrome.action.setBadgeText({ tabId, text: state === "withdrawn" ? "?" : "" });
5  await chrome.action.setTitle({
6    tabId,
7    title: state === "withdrawn" ? "Click to enable Readable on this site" : "Readable",
8  });
9});

Execution context: the service worker, top-level listener. Badge text and title are per tab, so each tab shows its own state. tabs.onUpdated fires for every tab, but without access the event omits url, which accessFor turns into “unknown”; prefer showing nothing to guessing. Firefox supports per-tab badges identically; Safari supports them but renders badges less prominently.

From withdrawn access to a working featureA page loads on a withdrawn host; the worker sets a badge hint; the user clicks the action, which grants the tab; the worker injects the feature and offers to remember the site.Page loadshost withdrawnBadge "?"title explainsUser clicksgrants this tabrun the feature now, then offer persistenceexecuteScriptfeature runsOffer "always on this site"permissions.requestRegister scriptfor the new host
The click both fixes the current tab and is the moment to ask about the future.

3. Treat the click as the trigger for automatic features

Under “on click”, a click on the action grants the tab. Use that moment to do what the content script would have done automatically.

1chrome.action.onClicked.addListener(async (tab) => {
2  await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ["auto.js"] });
3  await chrome.action.setBadgeText({ tabId: tab.id, text: "" });
4});

Execution context: the service worker. This handler only fires when the action has no popup. If the extension uses a popup, run the same injection from the popup’s startup code — opening the popup is the click. Make auto.js idempotent (guard with a global flag) because it may be injected both by the click and, later, by a content script registration.

4. Offer to remember the site

1// popup.js
2const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
3const origin = new URL(tab.url).origin;
4document.querySelector("#always").addEventListener("click", async () => {
5  const ok = await chrome.permissions.request({ origins: [`${origin}/*`] });
6  if (ok) await chrome.runtime.sendMessage({ type: "hosts:changed" });
7  window.close();
8});

Execution context: the popup, inside a click handler so the gesture is live. The origin must be covered by a pattern in host_permissions or optional_host_permissions; a request for an origin the manifest never mentioned is rejected. Granting adds the site to the user’s “specific sites” list in Chrome’s menu, so the user’s mental model and the browser’s state stay in sync.

5. Keep dynamic content scripts aligned with grants

Manifest content scripts are filtered automatically. Scripts registered with chrome.scripting.registerContentScripts are not re-filtered for you — if you register them with broad matches, injection silently skips ungranted hosts, which is fine, but your bookkeeping of “where the feature is on” drifts. Recompute from grants whenever they change.

 1chrome.permissions.onAdded.addListener(syncRegistrations);
 2chrome.permissions.onRemoved.addListener(syncRegistrations);
 3
 4async function syncRegistrations() {
 5  const { origins = [] } = await chrome.permissions.getAll();
 6  const matches = origins.filter((o) => !o.startsWith("https://api."));
 7  const existing = await chrome.scripting.getRegisteredContentScripts({ ids: ["auto"] });
 8  if (existing.length) await chrome.scripting.unregisterContentScripts({ ids: ["auto"] });
 9  if (matches.length) {
10    await chrome.scripting.registerContentScripts([{ id: "auto", matches, js: ["auto.js"], persistAcrossSessions: true }]);
11  }
12}

Execution context: the service worker, with listeners registered at the top level. persistAcrossSessions: true keeps the registration across browser restarts, so it only needs recomputing when grants change. Firefox supports the same API from version 102; Safari supports it with fewer options.

6. Write support copy that matches the menu

Users restrict access through a specific menu with specific wording. Your help page and in-product messages should use the same words — “Site access”, “On click”, “On specific sites”, “On all sites” — and show where to find them. A message that says “grant host permissions” means nothing to most users; one that says “Open the puzzle-piece menu, click ⋮ next to Readable, and choose ‘On all sites’” resolves the support ticket.

Cross-browser variation

  • Chrome / Edge: the three-way site access setting described here, plus permissions.onAdded/onRemoved events when it changes. Edge exposes the same menu with slightly different wording.
  • Firefox: no single “on click” mode, but per-host toggles in the add-on’s Permissions tab with the same effect; onRemoved fires when a host is toggled off.
  • Safari: per-site prompts with “Allow for one day”, “Always allow on this website” and “Always allow on every website”. Grants expire silently after a day, so check access on every use rather than relying on events.
How users restrict access in each engineThe user-facing control for restricting an extension's site access in Chrome, Firefox and Safari, and whether the extension is notified.EngineControlGranularityEvent firedChrome / EdgeExtensions menuClick / sites / allonRemoved / onAddedFirefoxAdd-on Permissions tabPer hostonRemoved / onAddedSafariPer-site promptDay / site / allUnreliable
Every engine lets users narrow access; only Chrome and Firefox reliably tell you.

Verification

  1. Install the extension and confirm automatic features work on a declared site.
  2. Set site access to “When you click the extension”. Reload the site: the feature should be absent, and the badge should show “?” with an explanatory tooltip.
  3. Click the action: the feature should run on that tab.
  4. Use the popup’s “Always on this site” and confirm the site appears under “On specific sites” in Chrome’s menu and the feature now runs on reload without a click.
1await chrome.permissions.getAll();
2// on click:          { origins: ["https://api.readable.example/*"] }
3// after "always":    { origins: ["https://api.readable.example/*", "https://news.site/*"] }

Execution context: the service worker console. Note that the API host may also be withdrawn under “on click” if it is a website host — keep API calls robust to that case.

FAQ

Can I prevent users from restricting access?

No, and attempting to work around it — for example, by asking for tabs and scripting via other means — will be treated as a policy violation. Design for restriction.

Does “on click” affect my backend API host?

It can. Chrome applies the setting to all host permissions, including your API’s. If the API is only called from the worker, users rarely notice because calls happen after clicks; if background sync depends on it, surface the failure clearly.

Is there a way to ask Chrome to show my extension’s request in the menu?

Recent Chrome versions add chrome.permissions.addHostAccessRequest(), which surfaces a request in the extensions menu for a given tab without a popup prompt. Feature-detect it and fall back to the badge hint.

Other MV3 Architecture & Extension Lifecycle Resources