Protecting Web Accessible Resources from Fingerprinting

Stop websites detecting and fingerprinting your MV3 extension: how sites probe web_accessible_resources, use_dynamic_url, narrow matches, removing exposed files, avoiding DOM tells and Firefox's per-install origins.

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

Fingerprinting scripts build a list of a visitor’s installed extensions by requesting known files at chrome-extension://<id>/<path> and noting which ones load. Each detected extension adds bits to a fingerprint that follows the user across sites, and some sites use detection to block or degrade users of privacy and ad-blocking tools. If your extension exposes resources through web_accessible_resources, it is detectable this way — by every site those resources are exposed to. This guide explains how the probing works and how to make your extension much harder to detect without breaking the features that need exposed files. It belongs to extension security and CSP hardening.

How sites detect extensions

The classic probe is a request for a resource at a stable URL: an <img> pointed at chrome-extension://abcdefghijklmnopabcdefghijklmnop/icons/logo.png, or a fetch to the same. If the extension is installed and that file is web accessible to the page, the request succeeds; otherwise it fails. Because Chrome extension ids are stable across installs and published in store URLs, a fingerprinting library can carry a list of thousands of (id, path) pairs and test them all quickly. Timing differences and error types can sometimes leak information even for resources that are not accessible. Beyond URL probing, sites can detect extensions by their effects: injected DOM elements, global variables leaked into the page, modified styles, or messages posted to the window.

A probe against a stable resource URLA fingerprinting script requests a known extension id and path; the browser serves it because the resource is web accessible to all URLs; the script records the extension as installed.Page scriptBrowserExtension packagefetch chrome-extension://<id>/icons/logo.pngweb accessible to <all_urls>?yes → read file200 → "extension installed"
A stable id plus a broadly exposed file is a detection beacon.

Step-by-step: reduce detectability

1. Expose as little as possible

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

Execution context: a terminal against the built manifest. Many extensions expose whole folders “just in case”. Every listed file is a potential probe target. Remove entries that no content script or page actually loads by URL — CSS injected through insertCSS or manifest css does not need to be web accessible, and UI rendered in a shadow root from bundled strings needs no exposed files at all.

2. Scope matches to the sites that need the files

1{
2  "web_accessible_resources": [{
3    "resources": ["fonts/inter-var.woff2"],
4    "matches": ["https://*.example.com/*"]          // not <all_urls>
5  }]
6}

Execution context: the manifest. A resource exposed only to example.com cannot be probed by an arbitrary fingerprinting script on another site. If your feature runs on a known list of sites, list them; the detection surface shrinks from “every page on the web” to “those sites”.

Detection surface by configurationNo exposed resources, exposure to specific sites, exposure to all URLs, and exposure to all URLs with use_dynamic_url compared on which pages can probe the extension and how reliably.ConfigurationWho can probeProbe reliabilityNo web-accessible resourcesNobody (by URL)NoneScoped matchesListed sitesHigh on those<all_urls>Every siteHigh<all_urls> + use_dynamic_urlEvery siteLow (no stable URL)
Fewer files, fewer origins, and dynamic URLs each cut the surface.

3. Turn on dynamic URLs

1{
2  "web_accessible_resources": [{
3    "resources": ["overlay/*"],
4    "matches": ["<all_urls>"],
5    "use_dynamic_url": true
6  }]
7}
1// content script — always build URLs with getURL
2const url = chrome.runtime.getURL("overlay/frame.html");

Execution context: the manifest and a content script. With use_dynamic_url, the resource is served from a URL containing a per-session random identifier instead of the stable extension id, so a list of known (id, path) pairs no longer works. Your own code must use chrome.runtime.getURL, which returns the dynamic form. Use it on every entry exposed broadly.

Stable versus dynamic resource URLsWithout use_dynamic_url the resource lives at a stable URL derived from the published extension id, which probes can guess; with it, the URL contains a per-session random token that only the extension's own code learns through getURL.Stablechrome-extension://<id>/xPublished idfrom store URLProbe succeedsguessablewith use_dynamic_urlDynamicchrome-extension://<random>/xgetURL onlyyour codeProbe failsnot guessable
A probe needs a URL it can guess — dynamic URLs remove it.

4. Avoid leaking presence through the DOM

1// Mount UI only when the user invokes a feature, inside a closed shadow root
2chrome.runtime.onMessage.addListener((m) => { if (m.type === "show-panel") mountPanel(); });
3
4// Do not leave markers on the page
5// document.documentElement.dataset.readableInstalled = "1";   ← a free detection signal

Execution context: a content script. A content script that injects UI on every page load, sets data attributes or classes on <html>, or defines page-visible globals (from main-world scripts) is detectable without any resource probing. Mount UI on demand, keep it in a closed shadow root, use the isolated world, and avoid custom elements whose tag names are unique to your extension where possible.

5. Keep main-world bridges quiet

1// If you must talk to the page's main world, use a per-session random channel name
2const channel = crypto.randomUUID();

Execution context: content scripts bridging to main-world scripts. A fixed event name or window.postMessage channel like "readable-ext" is a detection signal any page can listen for. A random channel name agreed between your isolated-world script and an injected main-world script at injection time reveals nothing reusable. See bridging data between main world and isolated world.

6. Consider an extension iframe only when needed

An iframe pointing at a web-accessible extension page is itself a probeable resource and a visible DOM element. Prefer shadow-root UI built from bundled code. When an iframe is necessary — for full isolation of complex UI — combine scoped matches, use_dynamic_url, and creating the frame only on user action.

7. Test detection from a page’s point of view

1// Run in a test page's console
2const id = "abcdefghijklmnopabcdefghijklmnop";
3for (const p of ["icons/128.png", "overlay/frame.html", "fonts/inter-var.woff2"]) {
4  fetch(`chrome-extension://${id}/${p}`).then((r) => console.log(p, r.ok), () => console.log(p, "blocked"));
5}

Execution context: a web page’s console, simulating a fingerprinting script. Every true is a detection path. Run this on a site outside your matches and on one inside them, before and after enabling use_dynamic_url.

Common mistakes

  • Exposing whole directories to <all_urls>. The largest detection surface possible.
  • Hard-coding chrome-extension://<id>/ in code. Breaks dynamic URLs and Firefox.
  • Injecting UI on every page load. Detectable without any probing.
  • Fixed main-world channel names. Pages can listen for them.
  • Assuming CSS needs exposure. Injected CSS does not; only URLs inside it do.

Cross-browser variation

  • Chrome / Edge: stable ids make URL probing effective; use_dynamic_url mitigates it.
  • Firefox: extension origins are a random UUID per install, so cross-site probing by a known id does not work; use_dynamic_url is ignored. DOM-based detection still applies.
  • Safari: per-install UUID origins similar to Firefox; per-site permissions further limit where content scripts run.

Verification

  1. List exposed resources and their matches; confirm each is needed.
  2. Run the probe script from a page outside your matches: all requests fail.
  3. Enable use_dynamic_url and confirm getURL returns a non-id host and stable-URL probes fail even on matched sites.
  4. Load matched pages without invoking the extension and confirm no extension DOM or attributes appear.

FAQ

Does use_dynamic_url break caching?

The URL is stable within a browser session, so caching works normally during a session.

Can sites detect extensions without web-accessible resources?

Only through effects — DOM changes, timing, behaviour. Keeping content scripts passive until invoked minimises those.

Is detection always malicious?

No — some sites detect an extension to integrate with it. Use externally_connectable for intentional integration rather than exposed files.

Should I rename files to make probing harder?

Obscure names add little — once one fingerprinting list includes your id and path, renaming helps only until the list is updated. Narrow matches and dynamic URLs change the situation structurally; renaming does not.

Does an exposed favicon or logo matter?

Yes, if exposed broadly: images are the most commonly probed resources because they load cheaply through an <img>. If a logo is only needed inside your shadow-root UI, inline it as an SVG or data URL instead of exposing it.

How do I know which of my files are being probed?

You cannot observe probes directly. Assume every broadly exposed, stable URL is in someone’s list, and minimise exposure accordingly.

Other MV3 Architecture & Extension Lifecycle Resources