Skip to main content
Every file in your extension is private to your extension until you say otherwise. A web page cannot load it, and neither can a content script that runs inside that page. The web_accessible_resources manifest key is the allowlist that opens specific files to specific origins. This page explains which callers need an entry, how the object form works, how globs and runtime URLs behave, and which console lines a missing entry prints.

Three ways a file gets read, and which one needs a declaration

From an extension page. A popup, an options page, a side panel, or a devtools page runs on the extension origin. It reads any file in the bundle, and no web_accessible_resources entry is involved. From a content script. The script’s own code is already running, but the assets that it pulls in are not covered by that. An img element, a fetch, a FontFace, or a dynamic import() that names a chrome-extension:// URL is a resource load, so the file must be listed. From the page’s own code. Anything in the page’s MAIN world, including a script that you injected with world: "MAIN", is web page code. It needs the file listed and it needs the page origin to fall inside matches. The rule to remember: the browser decides by who fetches the URL, not by who wrote the code.

Manifest snippet

Manifest V3 takes an array of objects. Each object pairs a list of resources with the matches patterns that are allowed to read them:
  • resources lists paths relative to the extension root. A relative path resolves from the folder that holds manifest.json.
  • matches limits which page origins may read those files. Keep it as narrow as the feature allows.
  • use_dynamic_url asks the browser for a URL that rotates per session, so a page cannot fingerprint your extension by a fixed id. Treat it as a Chromium field.
Globs are allowed in resources, and Extension.js keeps them as written. It does not expand fonts/*.woff2 into an explicit file list, so the emitted manifest carries the same pattern that your source carries. An explicit list is still the safer choice when you know the file names. Extension.js drops a Manifest V3 entry that has no resources array during normalization, because the browser cannot act on it.

Build the URL at runtime

Never write a chrome-extension:// URL by hand. Ask the runtime for it:
A root-absolute argument such as /images/logo.png is the stable form, and it is what Path resolution rewrites for you. The same call in a content script is the only correct way to reach the file, because a plain /images/logo.png in an injected stylesheet or element resolves against the host page instead. CSS works through that case for a web font. On Firefox the helper matters even more. Firefox gives each installation a random UUID in its moz-extension:// origin, so a URL that you copied from one profile is wrong in every other profile.

Per-browser differences

On Safari, enabling the extension is not enough. Until you grant website access, no content script runs on the page, so nothing asks for the resource in the first place. See Safari for the enable and grant steps. If your source is written against browser.* and you also build for a Chromium target, pass --polyfill so the namespace exists there. See Cross-browser compatibility.

Console lines you will see

Copy the line that you see into search. Each one maps to one cause. Denying load of chrome-extension://<id>/images/logo.png. Resources must be listed in the web_accessible_resources manifest key. The file is not in any resources array, or the page that asked for it is outside matches. Add the path, then confirm that the pattern covers the page origin. GET chrome-extension://invalid/ net::ERR_FAILED The same denial seen from the network panel. Chromium rewrites a blocked extension URL to chrome-extension://invalid/, so the id disappears from the request. Fix the manifest entry, not the fetch. Security Error: Content at https://example.com/ may not load or link to moz-extension://<uuid>/images/logo.png. The Firefox form of the same problem. The add-on did not list the file, so the page is not allowed to link to it. Failed to load resource: net::ERR_FILE_NOT_FOUND The entry is correct and the file is missing from the output. Check dist/<browser>/ for the exact path that you declared. Uncaught ReferenceError: browser is not defined A browser.runtime.getURL call reached a Chromium target with no polyfill. Build with --polyfill, or call chrome.runtime.getURL.

The Extension.js way

Extension.js merges what you declare with what the build discovers, then normalizes the result for each target:
  • Assets that a content script imports, content-script CSS output, and emitted fonts are added for you when the runtime needs the page to read them.
  • Paths are normalized for output. Extension.js removes a public/ prefix and a leading slash, so the entry names the file at the location where it lands.
  • Globs stay as written, and match patterns that carry a port or a port wildcard, such as http://localhost:3000/*, are accepted rather than rejected.
  • In development, Extension.js patches the entry set so reload and hot module replacement assets stay reachable. That patching is development only. See Reload and HMR.
Automatic merging is convenient, and it is not a review. Read the emitted dist/<browser>/manifest.json before a release and confirm that the exposed set is the set that you meant to expose:
  • Keep matches on the domains that actually need the file.
  • Prefer an explicit resource list over a broad glob.
  • Keep sensitive files out of the allowlist and serve them from an extension page instead.
  • Audit the list again after you add a content-script import, a font, or a new static asset.
Scaffold a project that loads an extension font into a page, which is the smallest complete example of this key:

See also