Fix "Denying load of chrome-extension://" with web_accessible_resources
6 min readModerok team
Why Chrome logs "Denying load of chrome-extension://", the web_accessible_resources manifest fix for MV3, and the reasons the entry still does not work.
When a web page tries to load a file out of your extension, and the page console prints Denying load of chrome-extension://<id>/<path>. Resources must be listed in the web_accessible_resources manifest key in order to be loaded by pages outside the extension., the file is simply not declared as web accessible. Add a web_accessible_resources entry naming the path and the sites allowed to reach it, then reload the extension from chrome://extensions:
{
"manifest_version": 3,
"name": "My Extension",
"version": "1.0.0",
"web_accessible_resources": [
{
"resources": ["images/logo.png", "inject.js"],
"matches": ["https://example.com/*"]
}
]
}
Editing manifest.json is not picked up by refreshing the page. Click the reload icon on the extension's card first, then reload the tab.
Why "Denying load of chrome-extension://" happens at all
Every file in your extension package is fetchable in principle, because it sits behind a real URL of the form chrome-extension://[PACKAGE ID]/[PATH]. What the manifest key controls is who outside the extension is allowed to ask for it. Chrome's web_accessible_resources manifest reference is explicit about the default and the reason for it: "By default no resources are web accessible, as this allows a malicious website to fingerprint extensions that a user has installed or exploit vulnerabilities (for example XSS bugs) in installed extensions."
So the block is not a bug or a permissions prompt you forgot. It is the closed default, and the next sentence on that page states who is inside it: "Only pages or scripts loaded from an extension's origin can access that extension's resources." Navigations get their own restatement: "A navigation from a web origin to an extension resource is blocked unless the resource is listed as web accessible."
That is why the error says "pages outside the extension". The request comes from a web origin even when the code making it is yours: a content script calling chrome.runtime.getURL("inject.js") and injecting a <script src> hands the URL to the page, and the page does the loading.
Four reasons the entry is there and the error is not gone
The object has resources but no matches or extension_ids. Manifest V3 replaced the flat array of strings with an array of objects, and Chrome's docs state the shape: "Each element must include a "resources" element and either a "matches" or "extension_ids" element." An object carrying only resources grants nothing. Use matches for web pages, extension_ids for other extensions, and both when you need both.
The match pattern has a path. This is the one that produces a second, separate error. Chrome matches these patterns by origin only: "Only the origin is used to match URLs." Narrowing to a section of a site does not work, and Chrome rejects the attempt outright: "Google Chrome emits an "Invalid match pattern" error if the pattern has a path other than '/*'." So "https://example.com/app/*" is invalid; write "https://example.com/*". Subdomain matching in the origin is supported.
The filename changed since you wrote the manifest. Bundlers hash output names. Vite, webpack, and the extension frameworks built on them will happily emit assets/inject-a1b2c3.js this build and assets/inject-d4e5f6.js the next, and a literal entry goes stale every time you rebuild. Chrome supports wildcards in resources for exactly this. Its reference gives both forms: "For example, "/images/*" exposes everything in the extension's images/ directory, recursively, while "*.png" exposes all PNG files." Widen the pattern rather than regenerating the manifest by hand, but widen it to the directory you ship build output in, not to "*".
You are hardcoding the URL. chrome-extension:// plus a literal ID works on your unpacked build and breaks on the published one, which has a different ID. Always build the URL with chrome.runtime.getURL(), which the chrome.runtime reference documents as converting "a relative path within an app/extension install directory to a fully-qualified URL". Paths in resources are documented as relative to the extension's root directory, so keep the two spellings consistent.
What still breaks after you add the key
declarativeNetRequest redirects. If you redirect a page request to a bundled file, listing it is not optional, and the docs do not tie the failure to the console line above. Chrome's declarativeNetRequest reference: "A declarativeNetRequest rule cannot redirect from a public resource request to a resource that is not web accessible. Doing so triggers an error. This is true even if the specified web accessible resource is owned by the redirecting extension."
Navigation from an incognito web page. Chrome's rules for navigating to a listed resource carry one carve-out: "Navigation is blocked in incognito mode unless the value of the "incognito" field is set to "split"." It sits under the docs' navigability rules, so it qualifies navigations from a web origin, not your own pages. If a site you support navigates to one of your listed resources and your extension is enabled in incognito, test that path in an incognito window rather than assuming the listing covers it.
Detectability. Listing a file is what makes your extension probeable: any site in matches can request the URL and learn you are installed. Chrome's content scripts guide notes that declaring a resource "also exposes the resources to any first-party or third-party scripts running on the same site". The mitigation is use_dynamic_url: true, which serves the resource under an ID that, per the manifest reference, "is generated per session. That means it is regenerated when the browser restarts or the extension reloads." The Chromium extensions team announced that the property was only wired up later ("Starting in Chrome 130, we will enable support for the use_dynamic_url property on entries under the web_accessible_resources key in the manifest."), having previously been "gracefully ignored", so check your minimum supported version before relying on it. Turning it on makes chrome.runtime.getURL() mandatory rather than merely advisable, since nothing else knows the current ID.
Which contexts never needed this
Your own extension pages are inside the extension, so the key does not apply to them. A popup, options page, or side panel loading its own CSS and images needs no entry, and neither does the background service worker fetching a JSON file it ships with. If a load fails in one of those, look elsewhere: an MV3 page has its own restrictions, covered in why an extension page refuses to execute inline script.
Content scripts are the exception that catches people, because they feel like extension code. They are not treated as extension origin for this purpose, and Chrome's content scripts guide says so about direct reads as well as injected URLs: "You can also access other files in your extension from a content script, using APIs like fetch(). To do this, you need to declare them as web-accessible resources."
So the boundary is: extension page or service worker loading its own file, no entry needed. Web page loading your file, content script handing a URL to the page, or content script reading the file itself, entry needed.
Firefox differences
Firefox accepts the same Manifest V3 object syntax, so one manifest usually covers both browsers, but the URLs are not interchangeable. MDN's web_accessible_resources page: "Firefox uses moz-extension://", and the host portion is not what you might assume: "<extension-UUID> is not your extension's ID." MDN gives both the behavior and the motive: "This ID is randomly generated for every browser instance." and "This prevents websites from fingerprinting a browser by examining the extensions it has installed."
The practical upshot is that Firefox gets the anti-fingerprinting property by default, which is what MDN presents use_dynamic_url as Chrome's route to: "In Manifest V3, Chrome can use a dynamic URL by setting use_dynamic_url to true." It also means any hardcoded moz-extension:// string is guaranteed wrong. If you maintain a shared manifest, how a Chrome MV3 service worker becomes a Firefox event page covers the other keys that diverge.
A note on what does not touch this key
Worth knowing while you are auditing the manifest: a library only forces a web_accessible_resources entry if it ships a file a web page has to fetch. Moderok's analytics SDK does not. It is bundled into whichever extension context imports it, background service worker or popup or content script, and it never hands a chrome-extension:// URL to the page, so there is no asset to list. Its source touches no window, document, or localStorage, and the setup asks for the storage permission and nothing else, so adding it leaves your web_accessible_resources list exactly as it was. The Moderok manifest guide has the full setup.