Declaring web_accessible_resources Correctly

Configure MV3 web_accessible_resources: the object format with resources, matches and extension_ids, use_dynamic_url, why resources fail to load, and how exposed files let sites fingerprint your extension.

Published October 2, 2026 Updated October 2, 2026 7 min read
Table of Contents

A content script injects an image or a font from the extension and the page shows a broken icon, with “Denied load of chrome-extension://…/icon.png. Resources must be listed in the web_accessible_resources manifest key” in the console. You add the file to the list, and it works — on every site, including the ones running scripts that probe for installed extensions by requesting exactly such URLs. web_accessible_resources is both the fix for blocked loads and the main way extensions leak their presence to web pages. Getting it right means listing only what pages truly need, only for the pages that need it. This guide belongs to the manifest keys reference.

Why pages cannot load extension files by default

Files inside an extension are served from chrome-extension://<id>/. Extension pages and the service worker can load any of them. Web pages — and content scripts, which load resources on behalf of the page they run in — cannot, unless the file is declared web accessible for that page’s origin. The rule exists for two reasons. Security: an extension page loaded inside a hostile site’s iframe could be clickjacked or fed messages. Privacy: if any site could request chrome-extension://<known id>/logo.png, every site could tell which extensions you have installed. MV3’s object format scopes each group of resources to specific origins, and use_dynamic_url replaces the stable id in the URL with a per-session token.

A resource request from a pageA page requests an extension resource; the browser checks the resource list and the page origin against web_accessible_resources, serving the file only if both match and otherwise blocking it.Web pageBrowserExtension packageGET chrome-extension://id/fonts/a.woff2listed in resources?page origin in matches?read file200 font data
Both the file and the requesting origin must be listed.

Step-by-step: expose the minimum

1. List only resources pages really load

Content scripts usually need far fewer web-accessible files than developers assume. CSS injected through the manifest or chrome.scripting.insertCSS does not need to be web accessible — but fonts, images and iframes it references by URL do.

1"web_accessible_resources": [
2  {
3    "resources": ["fonts/inter-var.woff2", "img/logo-32.png"],
4    "matches": ["https://*.example.com/*"]
5  }
6]

Execution context: the manifest. Each entry pairs a set of resource paths (glob patterns allowed) with the origins allowed to load them. Paths are relative to the extension root and must not start with /. A resource that appears in no entry is accessible only to the extension’s own pages.

2. Scope by origin, not with all_urls

1"web_accessible_resources": [
2  { "resources": ["overlay/*"],        "matches": ["https://*.example.com/*", "https://docs.example.org/*"] },
3  { "resources": ["icons/badge.svg"],  "matches": ["<all_urls>"], "use_dynamic_url": true }
4]

Execution context: the manifest. matches uses match-pattern syntax but only the scheme and host are significant — the path part must be /*. If a resource genuinely must work on every site, such as an icon your content script shows everywhere, pair <all_urls> with use_dynamic_url to limit fingerprinting. Separate entries let different resources have different exposure.

3. Build URLs with getURL

1// content.js
2const font = new FontFace("Inter", `url(${chrome.runtime.getURL("fonts/inter-var.woff2")})`);
3await font.load();
4document.fonts.add(font);
5
6const img = document.createElement("img");
7img.src = chrome.runtime.getURL("img/logo-32.png");

Execution context: a content script in the isolated world. chrome.runtime.getURL returns the right URL whether or not use_dynamic_url is in effect — with it, the host part is a session-specific token rather than the extension id. Hard-coding chrome-extension://<id>/ breaks dynamic URLs and breaks in Firefox and Safari, whose origins differ per install.

Exposure of a resource under different declarationsWhich pages can load a resource and whether it reveals the extension, for no declaration, scoped matches, all_urls, and all_urls with use_dynamic_url.DeclarationWho can load itFingerprintableNot listedExtension pages onlyNoScoped matchesListed originsBy those origins<all_urls>Every siteYes, by any site<all_urls> + dynamic URLEvery siteMuch harder
Scoped matches limit who can see the file; dynamic URLs limit what seeing it reveals.

4. Expose iframes deliberately

An extension page embedded in a site — a sidebar, an overlay, a sign-in frame — must be web accessible to the sites that embed it, and it is the highest-risk kind of exposure because it runs with extension privileges inside someone else’s page.

1{ "resources": ["frame.html", "frame.js", "frame.css"], "matches": ["https://*.example.com/*"] }
1// frame.js — the embedded extension page
2window.addEventListener("message", (e) => {
3  if (!/^https:\/\/([a-z0-9-]+\.)*example\.com$/.test(e.origin)) return;   // only the hosting site
4  handle(e.data);
5});

Execution context: an extension page loaded in an iframe on the site. It has the full extension API surface, so it must validate every message’s origin and treat message data as untrusted. Consider whether a shadow-DOM UI injected by the content script would serve instead — it needs no web-accessible HTML at all, as covered in injecting UI with shadow DOM without breaking the page.

5. Share resources with other extensions by id

1{ "resources": ["shared/schema.json"], "extension_ids": ["abcdefghijklmnopabcdefghijklmnop"] }

Execution context: the manifest. extension_ids grants access to specific extensions instead of web origins; "*" allows any extension. Use it for companion extensions from the same publisher. An entry needs matches, extension_ids, or both.

Does this file need to be web accessible?Decision tree: files used only by extension pages need no declaration; files loaded by URL from content scripts need scoped matches; iframed extension pages need scoped matches plus origin checks; files for other extensions use extension_ids.Who loads the file?extension pagesNo declarationpopup, options, workerStays privatenot fingerprintablecontent script URLScoped matchesfonts, imagesAdd dynamic URLif <all_urls>iframe on a siteScoped matchesframe.html + assetsCheck postMessage originprivileged pageanother extensionextension_idscompanion idsAvoid "*"name the ids
The default answer is no — declare only what a page or content script loads by URL.

6. Audit what you expose before each release

1jq -r '.web_accessible_resources[] | "\(.matches // .extension_ids | join(",")) → \(.resources | join(" "))"' dist/manifest.json

Execution context: a terminal or CI step against the built manifest. The output is a one-line-per-entry summary of who can load what. Review it like a firewall rule set: every line should map to a feature, the origins should be the narrowest that work, and any <all_urls> line should carry use_dynamic_url. Keeping the summary in the pull request description for manifest changes makes exposure changes visible to reviewers who would never read the raw JSON.

Common mistakes

  • Listing CSS that is injected, not loaded. Stylesheets passed to insertCSS or declared in content_scripts.css do not need to be web accessible. Only URLs inside them (fonts, background images) do.
  • Using a leading slash. "/img/logo.png" does not match; paths are relative to the root.
  • Declaring a whole directory with *. "resources": ["*"] exposes every file, including your source maps and HTML pages. List globs per directory instead.
  • Hard-coding the extension id in URLs. Breaks with use_dynamic_url, in Firefox, in Safari, and between development and production unless the key is pinned.
  • Forgetting the path in matches. "https://example.com" without /* is invalid; the manifest fails to load.

Cross-browser variation

  • Chrome / Edge: object format required in MV3; use_dynamic_url supported. Exposed resources with stable URLs are trivially detectable by any matching page.
  • Firefox: supports the object format in MV3 and ignores use_dynamic_url; Firefox extension origins already use a random per-install UUID, which makes cross-site fingerprinting by URL much harder by default.
  • Safari: supports the object format; origins use a per-install UUID. Some Safari versions have been stricter about loading web-accessible resources from content scripts in cross-origin frames — test iframe cases explicitly.

Verification

  1. Load a matching page and confirm the resource loads, with no “Denied load” message in the console.
  2. Load a non-matching page and run fetch(chrome-extension-url) from its console: it should fail.
  3. With use_dynamic_url, confirm chrome.runtime.getURL() returns a URL whose host is not the extension id.
  4. Grep the built manifest for "*" resource globs and <all_urls> matches, and justify each one.

FAQ

Do source maps need to be web accessible?

Only if you want DevTools on web pages to fetch them for content scripts. Shipping them web accessible exposes your source to every matching site; most teams keep source maps out of the package and upload them to their error tracker instead.

Can a page detect my extension without web accessible resources?

Not through URLs. It may still infer it from DOM changes your content script makes, which is a reason to keep injected UI inside a closed shadow root.

Does use_dynamic_url change on every load?

It changes per browser session. Within a session the URL is stable, so caching works normally.

Other MV3 Architecture & Extension Lifecycle Resources