Chrome Extension Web Accessible Resources — Best Practices
13 min readWeb Accessible Resources Patterns
Overview
The web accessible resources reference covers manifest configuration. This guide focuses on practical patterns: injecting UI into pages, secure resource loading, dynamic resource URLs, fingerprint protection, and communication between web pages and extension resources.
How Web Accessible Resources Work
Web accessible resources are extension files that web pages can load. In MV3, you must declare which origins can access which resources:
{
"web_accessible_resources": [
{
"resources": ["widget.html", "widget.js", "widget.css", "images/*"],
"matches": ["https://*.example.com/*"]
}
]
}
Without this declaration, web pages cannot load chrome-extension://<id>/widget.html — the request will fail.
Pattern 1: Injecting a Full UI Widget
Use web accessible resources to inject complex UI into web pages via iframes:
// content.ts — Inject an extension iframe into the page
function injectWidget() {
const iframe = document.createElement("iframe");
iframe.src = chrome.runtime.getURL("widget.html");
iframe.id = "my-ext-widget";
// Style the iframe to float over the page
Object.assign(iframe.style, {
position: "fixed",
bottom: "16px",
right: "16px",
width: "360px",
height: "480px",
border: "none",
borderRadius: "12px",
boxShadow: "0 4px 24px rgba(0, 0, 0, 0.2)",
zIndex: "2147483647",
});
// Prevent the page from interfering with the iframe
iframe.setAttribute("sandbox", "allow-scripts allow-same-origin");
document.body.appendChild(iframe);
return iframe;
}
<!-- widget.html — Served from extension, loaded in page context -->
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="widget.css" />
</head>
<body>
<div id="widget-root">
<header>
<h2>My Extension</h2>
<button id="close-widget">X</button>
</header>
<main id="widget-content"></main>
</div>
<script src="widget.js"></script>
</body>
</html>
Communication Between Iframe and Content Script
// widget.js — Inside the extension iframe
document.getElementById("close-widget")?.addEventListener("click", () => {
// Send message to parent content script
window.parent.postMessage({ type: "ext-widget-close" }, "*");
});
// Receive data from content script
window.addEventListener("message", (event) => {
if (event.data?.type === "ext-widget-data") {
renderData(event.data.payload);
}
});
// content.ts — Listen for messages from the widget iframe
window.addEventListener("message", (event) => {
// Verify the message is from our iframe
if (event.data?.type === "ext-widget-close") {
document.getElementById("my-ext-widget")?.remove();
}
});
// Send data to the widget
function sendToWidget(data: unknown) {
const iframe = document.getElementById("my-ext-widget") as HTMLIFrameElement;
iframe?.contentWindow?.postMessage(
{ type: "ext-widget-data", payload: data },
chrome.runtime.getURL("")
);
}
Pattern 2: Injecting CSS from Extension
Load extension-bundled stylesheets into web pages:
// content.ts — Inject extension CSS
function injectExtensionCSS() {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = chrome.runtime.getURL("injected-styles.css");
document.head.appendChild(link);
return link;
}
// Or inject as a style element to avoid FOUC
async function injectCSSInline() {
const url = chrome.runtime.getURL("injected-styles.css");
const response = await fetch(url);
const css = await response.text();
const style = document.createElement("style");
style.textContent = css;
style.setAttribute("data-ext-id", chrome.runtime.id);
document.head.appendChild(style);
return style;
}
Declare in manifest:
{
"web_accessible_resources": [
{
"resources": ["injected-styles.css"],
"matches": ["<all_urls>"]
}
]
}
Pattern 3: Extension Images in Web Pages
Display extension-bundled images in content scripts:
// content.ts
function createExtensionImage(path: string, alt: string): HTMLImageElement {
const img = document.createElement("img");
img.src = chrome.runtime.getURL(path);
img.alt = alt;
return img;
}
// Usage
const logo = createExtensionImage("images/logo.png", "Extension logo");
document.getElementById("my-ext-container")?.appendChild(logo);
SVG Icons
// Load SVG as inline content for CSS styling
async function inlineExtensionSVG(path: string): Promise<SVGElement> {
const url = chrome.runtime.getURL(path);
const response = await fetch(url);
const svgText = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(svgText, "image/svg+xml");
return doc.documentElement as unknown as SVGElement;
}
Pattern 4: Dynamic Resource URL Generation
Generate URLs at runtime for resources that depend on state:
// content.ts
function getThemedResource(resource: string): string {
// All variants must be declared in web_accessible_resources
return chrome.runtime.getURL(`themes/${getCurrentTheme()}/${resource}`);
}
// Manifest must include all possible paths
// "resources": ["themes/light/*", "themes/dark/*"]
URL with Cache Busting
// Force reload of cached resources after extension update
function getVersionedURL(path: string): string {
const version = chrome.runtime.getManifest().version;
return `${chrome.runtime.getURL(path)}?v=${version}`;
}
Pattern 5: Secure Resource Access
Restrict which origins can access your resources to prevent fingerprinting and data exfiltration:
{
"web_accessible_resources": [
{
"resources": ["widget.html", "widget.js", "widget.css"],
"matches": ["https://*.myapp.com/*"]
},
{
"resources": ["shared-config.json"],
"extension_ids": ["abcdefghijklmnopqrstuvwxyz"]
},
{
"resources": ["content-injected.css"],
"matches": ["<all_urls>"],
"use_dynamic_url": true
}
]
}
Dynamic URLs (Chrome 110+)
Dynamic URLs change on every browser session, preventing fingerprinting:
// With use_dynamic_url: true, chrome.runtime.getURL() returns
// a session-specific URL that changes on restart.
// This prevents websites from using the predictable
// chrome-extension://<id>/path pattern to detect your extension.
const dynamicUrl = chrome.runtime.getURL("content-injected.css");
// Returns something like: chrome-extension://abc.../content-injected.css?dynamic=xyz
Pattern 6: Injecting Scripts into the Page World
Sometimes you need to run code in the page’s JavaScript context (not the isolated content script world). Web accessible resources enable this:
// content.ts — Inject a script that runs in the page's world
function injectPageScript(scriptPath: string) {
const script = document.createElement("script");
script.src = chrome.runtime.getURL(scriptPath);
script.type = "module";
(document.head || document.documentElement).appendChild(script);
script.onload = () => script.remove(); // clean up the script tag
}
injectPageScript("page-script.js");
// page-script.js — Runs in the page's world, can access page variables
// This file MUST be declared in web_accessible_resources
// Intercept fetch calls (page world only)
const originalFetch = window.fetch;
window.fetch = async function (...args) {
const response = await originalFetch.apply(this, args);
// Notify the content script about the request
window.postMessage({
type: "ext-fetch-intercepted",
url: args[0]?.toString(),
status: response.status,
}, "*");
return response;
};
// content.ts — Receive messages from page script
window.addEventListener("message", (event) => {
if (event.source !== window) return;
if (event.data?.type === "ext-fetch-intercepted") {
// Forward to background
chrome.runtime.sendMessage({
type: "fetch-logged",
url: event.data.url,
status: event.data.status,
});
}
});
MV3 Alternative: chrome.scripting.executeScript with MAIN world
// background.ts — Preferred MV3 approach (no web_accessible_resources needed)
chrome.scripting.executeScript({
target: { tabId },
world: "MAIN",
func: () => {
// This runs in the page's JavaScript context
console.log("Running in page world");
},
});
Pattern 7: Font Loading
Load custom fonts from your extension:
/* injected-styles.css — declared as web accessible */
@font-face {
font-family: "ExtensionFont";
src: url("chrome-extension://__MSG_@@extension_id__/fonts/custom.woff2") format("woff2");
/* Note: __MSG_@@extension_id__ only works in CSS files, not in JS */
}
.ext-widget {
font-family: "ExtensionFont", system-ui, sans-serif;
}
Better approach using JavaScript:
// content.ts
function loadExtensionFont() {
const fontUrl = chrome.runtime.getURL("fonts/custom.woff2");
const font = new FontFace("ExtensionFont", `url(${fontUrl})`);
font.load().then((loaded) => {
document.fonts.add(loaded);
});
}
Pattern 8: Resource Preloading
Preload web accessible resources for faster injection:
// content.ts — Preload resources that will be needed
function preloadExtensionResources() {
const resources = [
"widget.html",
"widget.css",
"widget.js",
"images/logo.png",
];
for (const resource of resources) {
const link = document.createElement("link");
link.rel = "preload";
link.href = chrome.runtime.getURL(resource);
link.as = resource.endsWith(".css") ? "style"
: resource.endsWith(".js") ? "script"
: resource.endsWith(".html") ? "document"
: "image";
document.head.appendChild(link);
}
}
// Call early, before the resources are actually needed
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", preloadExtensionResources);
} else {
preloadExtensionResources();
}
Security Considerations
Extension Fingerprinting
Any web page in your matches list can probe for your extension by trying to load its resources:
// A malicious page could do this:
const img = new Image();
img.src = "chrome-extension://known-extension-id/images/logo.png";
img.onload = () => console.log("Extension is installed!");
img.onerror = () => console.log("Extension not found");
Mitigations:
- Use
use_dynamic_url: true(Chrome 110+) - Restrict
matchesto only the origins that need access - Minimize the number of web accessible resources
- Never make sensitive configuration files web accessible
Content Security Policy
Web accessible resources loaded in iframes respect the extension’s CSP, not the page’s. This provides isolation but means your widget code runs under extension CSP rules.
Summary
| Pattern | Use Case |
|---|---|
| iframe widget | Rich UI overlays on web pages |
| CSS injection | Styled content modifications |
| Extension images | Badges, icons, logos in page content |
| Dynamic URLs | Prevent extension fingerprinting |
| Page world scripts | Intercept page APIs, access page variables |
| Font loading | Custom typography in injected UI |
| Resource preloading | Faster widget initialization |
| Secure access | Restrict to specific origins only |
Web accessible resources are the bridge between your extension and the web page. Use them deliberately — expose only what’s needed, restrict access to specific origins, and prefer use_dynamic_url to prevent fingerprinting.
-e
—
Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.