Handling Restricted URLs and Tab Permissions

Detect the tabs an MV3 extension may never touch — chrome://, the Web Store, PDF viewers and other extensions' pages — and degrade gracefully instead of throwing.

Published September 18, 2026 Updated September 18, 2026 8 min read
Table of Contents

Every extension eventually gets a bug report that reads “it does nothing on some pages”. The pages in question are the ones the browser protects: chrome:// internals, the extension gallery, the new tab page, another extension’s pages, and in some builds the built-in PDF viewer. No host permission grants access to them, and an injection attempt rejects with a message most users will never see. Detecting them first is the difference between a broken feature and a clear one. This guide is part of tabs API and window management.

What is off limits, and why

Pages an extension cannot scriptRestricted URL classes with the reason each is protected and what an extension can still do with the tab.URL classCan injectCan read tab.urlWhychrome:// and about:NoWith tabs permissionBrowser internalschromewebstore.google.comNoYesProtects install flowOther extensions' pagesNoYesCross-extension isolationThe default new tab pageNoYesBrowser-owned surfaceview-source: and file:Only with opt-inYesUser must allow file acce…Built-in PDF viewerNot the viewer UIYesEmbedded plugin document
In every case you can read the tab record; what you cannot do is run code inside it.

Step-by-step

1. Write one predicate and use it everywhere

 1const BLOCKED_SCHEMES = ["chrome:", "chrome-untrusted:", "about:", "edge:", "moz-extension:",
 2                         "chrome-extension:", "devtools:", "view-source:"];
 3const BLOCKED_HOSTS = ["chromewebstore.google.com", "chrome.google.com", "addons.mozilla.org"];
 4
 5export function isScriptable(url) {
 6  if (!url) return false;                       // no tabs permission, or a tab still loading
 7  let u;
 8  try { u = new URL(url); } catch { return false; }
 9  if (BLOCKED_SCHEMES.includes(u.protocol)) return false;
10  if (BLOCKED_HOSTS.includes(u.hostname)) return false;
11  if (u.protocol === "file:") return false;     // needs explicit file-access opt-in
12  return u.protocol === "http:" || u.protocol === "https:";
13}

Execution context: a shared module, usable from the service worker and from extension pages alike. Note chrome-extension: is blocked here even for your own pages — inject into those with chrome.scripting only when you specifically mean to, since they already share your origin.

2. Check before you inject, not after

 1async function runOnActiveTab() {
 2  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
 3  if (!tab) return;
 4
 5  if (!isScriptable(tab.url)) {
 6    await chrome.action.setBadgeText({ tabId: tab.id, text: "—" });
 7    await chrome.action.setTitle({ tabId: tab.id, text: "Not available on this page" });
 8    return;
 9  }
10
11  await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ["content/run.js"] });
12}

Execution context: the service worker. Setting a per-tab title and badge is the cheapest honest feedback available — it explains the absence without a notification the user did not ask for.

3. Handle the rejection anyway

A URL can change between the query and the injection, and activeTab grants can expire. Treat the rejection as expected rather than exceptional.

 1async function tryInject(tabId, files) {
 2  try {
 3    return await chrome.scripting.executeScript({ target: { tabId }, files });
 4  } catch (err) {
 5    if (/Cannot access|Extension manifest must request permission|chrome:\/\//i.test(err.message)) {
 6      return null;                              // restricted or not granted — a normal outcome
 7    }
 8    throw err;                                  // anything else is a real bug
 9  }
10}

Execution context: the service worker. Chrome, Firefox and Safari all word this rejection differently, which is why the check matches loosely; the important part is that a restricted page returns null rather than propagating.

4. Know when tab.url will be empty

Without the tabs permission or a matching host permission, tab.url is undefined — and your predicate will correctly say “not scriptable” for pages you could have handled. Under activeTab, the URL becomes readable only after the user invokes the extension.

1chrome.action.onClicked.addListener(async (tab) => {
2  // activeTab is granted for this tab right now, so tab.url is populated here.
3  if (!isScriptable(tab.url)) return;
4  await tryInject(tab.id, ["content/run.js"]);
5});

Execution context: the service worker, inside the click handler where the activeTab grant is live. The grant lasts until the tab navigates or closes — the lifecycle covered in injecting only after a user gesture with activeTab.

5. Offer file access rather than failing silently

file:// URLs are scriptable, but only if the user ticked “Allow access to file URLs”. You can detect the setting and ask.

1const allowed = await chrome.extension.isAllowedFileSchemeAccess();
2if (!allowed) {
3  await chrome.tabs.create({ url: `chrome://extensions/?id=${chrome.runtime.id}` });
4}

Execution context: the service worker. You cannot grant the setting yourself; opening the details page is as close as an extension may get, and it is far better than a feature that appears broken on local files.

What to do with a tab you cannot scriptA decision tree separating permanently restricted pages, pages awaiting a permission grant, and pages where the URL is simply not readable yet.Why can't you run here?Browser-owned pageExplain, do not promptbadge + titleDisable the actionaction.disable(tabId)Host not grantedOffer to request itpermissions.requestFrom a user gesturepopup buttonURL not readableWait for the gestureactiveTab fills it inRe-check in onClickedtab.url is populated
Only the middle branch is worth prompting about — the others should be quiet and explanatory.

Three reasons a tab looks unavailable, and telling them apart

“Cannot run here” covers three quite different situations, and an extension that words them identically will be judged as broken in two of the three. The user-facing difference is whether there is anything they can do about it.

Permanently restricted. A chrome:// page, the Web Store, another extension. Nothing the user does will help. The right response is a disabled action with an explanatory tooltip and no prompt of any kind.

Not yet permitted. An ordinary site the extension has no host permission for. The user can fix this in one click, and should be offered the chance:

1async function offerAccess(tab) {
2  const origin = new URL(tab.url).origin + "/*";
3  const has = await chrome.permissions.contains({ origins: [origin] });
4  if (has) return true;
5  return chrome.permissions.request({ origins: [origin] });   // needs a gesture
6}

Execution context: an extension page such as the popup, inside a click handler. Calling this from the worker outside a gesture rejects, which is the most common reason a “grant access” button silently fails.

Unknown. The URL is not readable, because there is neither a host permission nor an active activeTab grant. This is the case most often misreported: the extension says “not supported on this page” about a page it supports perfectly well. The honest response is to say nothing until the user invokes the extension, at which point activeTab fills in the URL and the real answer becomes available.

1function classify(tab) {
2  if (!tab?.url) return "unknown";
3  return isScriptable(tab.url) ? "ok" : "restricted";
4}

Execution context: a shared module. Three states, three different pieces of copy, and only one of them worth a prompt — that distinction is the entire difference between an extension that feels considered and one that feels broken.

Three unavailable states and the right responseRestricted pages, ungranted hosts and unreadable URLs compared on what the user can do, what the action should show, and whether to prompt.StateUser can fix itAction statePrompt?Restricted pageNoDisabled + tooltipNeverHost not grantedYes, one clickEnabled, badge hintOn clickURL unreadableImplicitly, by clickingEnabled, neutralNo — wait for the gesture…
Prompting in the first or third row is what makes an extension feel pushy; staying silent in the second makes it feel broken.

Cross-browser variation

  • Chrome / Edge: blocks chrome://, chrome-untrusted://, the Web Store origins and other extensions’ pages. Enterprise policy can add runtime_blocked_hosts, which produces the same rejection on ordinary sites — worth mentioning in your error copy.
  • Firefox: blocks about: pages, addons.mozilla.org and its own reader view. Firefox is stricter about file: access and exposes no equivalent of isAllowedFileSchemeAccess; the user grants it per-extension in the add-on settings.
  • Safari: blocks Safari’s own settings and start pages. Safari’s tab records are also more likely to omit url for tabs the extension has not been granted, so treat an empty URL as “unknown” rather than “restricted”.
  • All three: the extension gallery for each browser is protected on every engine, not only on its own. A Chrome extension cannot script addons.mozilla.org either.

Verification

  1. Sweep the currently open tabs and see what your predicate says:
1(await chrome.tabs.query({})).map((t) => [t.id, t.url ?? "(hidden)", isScriptable(t.url)]);
2// [[12, "https://example.com/", true], [13, "chrome://extensions/", false], [14, "(hidden)", false]]

Execution context: the service worker console. A (hidden) entry means no permission for that tab, not that the tab is restricted — the two look the same to the predicate and should be worded differently to the user.

  1. Open chrome://extensions and click your action; confirm the badge reads as unavailable and nothing throws in the console.
  2. Open a local .html file, confirm the file-access prompt path, then enable the setting and confirm injection succeeds.

FAQ

Can I ask for permission to script chrome:// pages?

No. There is no permission that grants it, and any workaround that appears to is a policy violation. DevTools extensions reach a narrow, sanctioned slice of this through chrome.devtools, covered in building a custom DevTools panel.

Why does my extension work on PDFs in one browser and not another?

The built-in PDF viewer is itself an extension-like surface. Chrome exposes the embedding page but not the viewer’s internal document; Firefox’s pdf.js is a regular page in some configurations. Test explicitly rather than assuming.

Should I disable the toolbar action entirely on restricted pages?

Disabling with chrome.action.disable(tabId) is clearer than a button that opens a popup saying “not here”. Keep the tooltip informative so the user knows why — the per-tab pattern in enabling and disabling the toolbar action per tab.

Other Core APIs & Cross-Browser Data Management Resources