Skip to content

Fix "Cannot access contents of url" in a Chrome extension

7 min readModerok team

Why a Chrome extension throws "Cannot access contents of url", the host_permissions and activeTab fix, and the URLs no manifest key can unlock.

When a Chrome extension logs Cannot access contents of url "https://example.com/". Extension manifest must request permission to access this host., the tab you targeted is not covered by a host permission your extension currently holds. Add a match pattern that covers it, or declare activeTab if the user always clicks something first:

{
  "manifest_version": 3,
  "permissions": ["scripting"],
  "host_permissions": ["https://example.com/*"]
}

Then reload the extension from chrome://extensions, because manifest edits are not picked up by a page refresh. The rest of this post is the harder half: the key is already there and the error still fires.

Why Chrome raises it at all

chrome.scripting.executeScript() and chrome.scripting.insertCSS() need two separate things, and the scripting permission is only one of them. Chrome's scripting reference states both: "To use the browser.scripting API, declare the "scripting" permission in the manifest plus the host permissions for the pages to inject scripts into. Use the "host_permissions" key or the "activeTab" permission, which grants temporary host permissions." Miss the second half and the call fails on permission, not on your code.

The wording varies by API. Reports on the chromium-extensions group and GitHub issue trackers show Cannot access contents of url "<url>". Extension manifest must request permission to access this host. from executeScript, and Cannot access contents of the page. Extension manifest must request permission to access the respective host. where no URL is available to print. In callback-style code it arrives as Unchecked runtime.lastError, so if you are seeing nothing at all, start with logging chrome.runtime.lastError.

Check the match pattern before you blame anything else

Two rules in Chrome's match pattern reference account for most false negatives.

The scheme list is short: http, https, file, and a wildcard, and the docs are specific about that wildcard: "A wildcard *, which matches only http or https". So *://*/* does not cover file:// URLs, however broad it looks.

The path is also not optional in the manifest, even though it is not used for matching: "For host permissions, the path is required but ignored. The wildcard (/*) should be used by convention." "host_permissions": ["https://example.com"] with no trailing path is an invalid pattern, and an invalid pattern grants nothing.

The URLs no manifest key will ever unlock

If the URL in the error is a chrome:// page, the Chrome Web Store, or one of your own extension pages, there is no manifest key to add. This is a deliberate privilege boundary, described on MDN's content scripts page: "Both host permissions and the activeTab permission have exceptions for some domains. Content scripts are blocked from executing on these domains, for example, to protect the user from an extension escalating privileges through special pages." The same page notes the Chrome case: "For example, access to chrome.google.com is restricted in Chrome."

Extension pages hit hardest, because an error naming chrome-extension://<your-id>/newTab.html looks like a bug in your own manifest. It is not. MDN's scripting.executeScript reference puts it in one line: "Extensions cannot run content scripts in extension pages." Your options page, popup, side panel, and any override page load their own <script> tags already, so put the code in the page and drive it with chrome.runtime.sendMessage.

Privileged browser UI is out too. MDN lists it: "Extensions cannot inject content scripts into privileged browser UI pages (such as about:debugging, about:addons, reader view, view-source, or the PDF viewer) or extension pages." Chrome's activeTab documentation states the browser side of the same rule: "Access is not granted to restricted pages, such as chrome:// pages."

local files need a pattern and a user toggle

file:///* is the one pattern that is not sufficient on its own. Chrome's match pattern docs flag both the syntax and the catch: "Allows your extension to run on local files. This pattern requires the user to manually grant access." and "Note that this case requires three slashes, not two."

The toggle lives outside your manifest, so check it at runtime:

const ok = await chrome.extension.isAllowedFileSchemeAccess();
if (!ok) {
  // Ask the user to enable "Allow access to file URLs" on the extension's card.
}

Chrome's reference for that method describes exactly which switch it reads: "This corresponds to the user-controlled per-extension 'Allow access to File URLs' setting accessible via the chrome://extensions page." You cannot flip it programmatically, and it is off by default.

Declared is not the same as granted

A pattern in host_permissions is a request, not a guarantee. MDN's host_permissions reference states the consequence: "Users can grant or revoke host permissions on an ad hoc basis. Therefore, most browsers treat host_permissions as optional." A user who set your extension's site access to "on click" will produce this error on a site you clearly declared.

Verify instead of assuming, with chrome.permissions.contains(), which "Checks if the extension has the specified permissions":

const url = new URL(tab.url);
const origin = `${url.origin}/*`;

if (!(await chrome.permissions.contains({ origins: [origin] }))) {
  const granted = await chrome.permissions.request({ origins: [origin] });
  if (!granted) return;
}

await chrome.scripting.executeScript({
  target: { tabId: tab.id },
  files: ["content.js"],
});

chrome.permissions.request() must run in response to a user gesture, and origins you discover at runtime have to be covered by optional_host_permissions. Chrome's permissions guide gives the catch-all form: "If you want to request hosts that you only discover at runtime, include "https://*/*" in your extension's optional_host_permissions field."

Enterprise policy is the other invisible revoker: MDN notes that "Chrome's runtime_blocked_hosts policy is documented at Configure ExtensionSettings policy", so a managed profile can block hosts your manifest declares.

tab.url being undefined is a symptom, not the cause

Debugging this usually starts with printing the URL, and on a restricted page there is often nothing to print. Chrome's tabs.Tab reference explains why: url is "The last committed URL of the main frame of the tab. This property is only present if the extension has the "tabs" permission or has host permissions for the page." The same condition applies to pendingUrl, title, and favIconUrl.

Chrome's own activeTab sample turns that into a guard, with the comment "If the tab URL is not set, it is a restricted page":

chrome.action.onClicked.addListener((tab) => {
  if (!tab.url) return; // restricted page, or no permission to read the URL
  chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ["content.js"] });
});

What still breaks after the fix

  • activeTab expires. Chrome's docs: "Access to the tab lasts while the user is on that page, and is revoked when the user navigates away or closes the tab." An injection started after a redirect, or on a timer, can lose the grant it began with.
  • Blank and generated frames stay excluded. Per MDN, "By default, content scripts do not run in about:blank, about:srcdoc, data:, and blob: pages. To enable their execution, use the match_origin_as_fallback option in the content_scripts manifest key or the matchOriginAsFallback option in the scripting API."
  • A granted host permission is not a working message channel. Injection succeeding does not mean your listener is there yet, which is the separate failure behind "Receiving end does not exist".
  • Broad patterns cost you at install time, which is the trade-off behind the "read and change all your data" warning.

Firefox differences

Firefox raises the same class of failure, but with different edges.

Partial permissions behave differently. MDN's scripting.executeScript reference: "In Firefox and Safari, partial lack of host permissions can result in a successful execution (with the partial results in the resolved promise). In Chrome, any missing permission prevents any execution from happening." Code that reads an empty result array as "blocked" will misread Firefox.

The restricted set differs too. MDN lists Firefox's blocked domains, including accounts.firefox.com, addons.mozilla.org, support.mozilla.org, and sync.services.mozilla.com, with a warning that matters for onboarding: "Because these restrictions include addons.mozilla.org, users who try to use your extension immediately after installation may find that it doesn't work." Test the first-run path from a normal tab, not the add-on's listing page.

Install-time expectations differ as well. Per MDN, "From Firefox 127, host permissions listed in host_permissions and content_scripts are displayed in the install prompt. However, if an extension update requests new host permissions, these are not shown to the user."

Instrumenting the failure instead of guessing

Most of these causes are user state or platform state, so they happen on other people's machines and never on yours. Logging which branch a failed injection took (restricted scheme, revoked site access, missing file access) gives you a distribution instead of a hunch. Moderok's SDK is one way to collect that from a service worker without adding to the problem: it uses only chrome.runtime and chrome.storage, so it needs the storage permission and no host_permissions at all, as its manifest and permissions guide sets out.