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 noweb_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 ofresources with the matches patterns that are allowed to read them:
resourceslists paths relative to the extension root. A relative path resolves from the folder that holdsmanifest.json.matcheslimits which page origins may read those files. Keep it as narrow as the feature allows.use_dynamic_urlasks 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.
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 achrome-extension:// URL by hand. Ask the runtime for it:
/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.
dist/<browser>/manifest.json before a release and confirm that the exposed set is the set that you meant to expose:
- Keep
matcheson 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.

