Content Scripts & DOM Injection
Inject JavaScript and CSS into web pages with MV3 content scripts — isolated worlds, declarative vs programmatic injection, run_at timing, MAIN world risks, and Shadow DOM UI patterns.
Inject JavaScript and CSS into web pages with MV3 content scripts — isolated worlds, declarative vs programmatic injection, run_at timing, MAIN world risks, and Shadow DOM UI patterns.
Without content scripts your extension is blind to the page — it cannot read the DOM, intercept user interaction, or insert UI elements. The invisible trap: content scripts do not share a JavaScript runtime with the host page. They run in an isolated world that shares only the DOM tree, which means window globals, prototype chains, and event listeners from the host are completely separate. Get that boundary wrong and you spend hours debugging undefined reads and CSP errors. This guide covers the full injection model as part of the Manifest V3 Architecture & Extension Lifecycle overview.
scripting permission — required for chrome.scripting.executeScript() and chrome.scripting.insertCSS().activeTab — the manifest must declare matching host patterns (e.g., "https://*.example.com/*") or you must hold the activeTab grant from a user gesture.content_scripts key — only for declarative injection; programmatic injection does not need it.js: or files: must ship inside the extension package.web_accessible_resources — required if your content script loads extension assets (images, CSS) via chrome.runtime.getURL().Declarative registration is the right default for any extension that targets a consistent set of URL patterns. The browser handles injection timing automatically — no code in the service worker required.
1{
2 "manifest_version": 3,
3 "name": "Highlighter",
4 "permissions": ["storage"],
5 "content_scripts": [
6 {
7 "matches": ["https://*.example.com/*", "https://docs.example.org/*"],
8 "js": ["content/highlighter.js"],
9 "css": ["content/highlighter.css"],
10 "run_at": "document_idle", // after DOMContentLoaded + subresource quiet period
11 "all_frames": false // top-level frame only (default)
12 }
13 ]
14}
Execution context: manifest.json, evaluated by the browser at install time. run_at accepts three values: document_start (before any DOM exists), document_end (after DOM is ready but scripts may still run), document_idle (after DOMContentLoaded and a short quiet period — usually the correct default). Firefox respects all three; Safari respects them but document_start can misbehave on heavy pages.
Use chrome.scripting.executeScript() when injection must be conditional — gated on user action, runtime data, or feature flags — rather than URL-pattern-driven.
1// service-worker.js (background)
2chrome.action.onClicked.addListener(async (tab) => {
3 if (!tab.id || !tab.url?.startsWith("https://")) return;
4
5 try {
6 const [result] = await chrome.scripting.executeScript({
7 target: { tabId: tab.id },
8 files: ["content/highlighter.js"],
9 });
10 console.log("Injection result:", result.result);
11 } catch (err) {
12 // NotAllowedError: user has not granted host permission
13 console.error("Injection failed:", (err as Error).message);
14 }
15});
Execution context: Service worker background context. chrome.scripting is unavailable in content scripts themselves. files must be extension-local paths; passing an arbitrary URL throws. Firefox requires browser.scripting (or a polyfill) — the chrome.scripting alias is not universally available on Firefox MV3 yet. Safari supports browser.scripting.executeScript() from Safari 17.
The world parameter is one of the highest-risk knobs in MV3. Understand it before touching it.
ISOLATED world (default): Your script gets its own window object, its own prototype chain, and full access to chrome.* APIs. The host page cannot read or overwrite your globals. This is what you should use for 95 % of injection tasks.
MAIN world: Your script runs inside the host page’s JavaScript context. You can read window.dataLayer, call page-defined functions, and listen to events fired in the page’s own runtime. The price: you lose all chrome.* API access, you are exposed to prototype pollution from the host, and you must never trust host-page data without sanitising it.
1// Use MAIN world only to read page-level globals you cannot reach from ISOLATED
2await chrome.scripting.executeScript({
3 target: { tabId },
4 world: "MAIN",
5 func: () => {
6 // No chrome.* APIs available here
7 return window.__APP_CONFIG__?.apiEndpoint ?? null;
8 },
9});
Execution context: Service worker initiates; the func body runs in the tab’s MAIN JavaScript context. Chrome 102+ only. Firefox added world: 'MAIN' in Firefox 128. Safari does not support world: 'MAIN'; bridge data with window.postMessage or CustomEvent from an ISOLATED content script instead.
Communicating between MAIN and ISOLATED worlds happens through the DOM event system — not through any extension API. Prefer CustomEvent over window.postMessage when the source is your own code, and always validate payloads before acting on them. The full threat-surface analysis and secure bridging patterns live in best practices for content script isolation in MV3.
Content scripts have access to chrome.runtime.sendMessage() and chrome.runtime.connect() but cannot call chrome.tabs or initiate fetches to arbitrary origins (cross-origin XHR from a content script is blocked by CORS). Route remote requests through the service worker.
1// content/highlighter.js (ISOLATED world)
2async function reportSelection(selectedText: string) {
3 const response = await chrome.runtime.sendMessage({
4 type: "SELECTION",
5 text: selectedText,
6 url: location.href,
7 });
8 if (response?.saved) {
9 showToast("Saved");
10 }
11}
12
13document.addEventListener("mouseup", () => {
14 const sel = window.getSelection()?.toString().trim();
15 if (sel) reportSelection(sel);
16});
Execution context: Content script in ISOLATED world. chrome.runtime.sendMessage() serialises the payload through structured clone and delivers it to the service worker’s chrome.runtime.onMessage listener. Firefox and Safari support the same pattern under browser.runtime. Do not use long-lived sendMessage for fire-and-forget updates where message ordering matters — prefer chrome.runtime.connect() for that pattern.
Appending plain elements directly to document.body leaks them into the host page’s stylesheet cascade. The host page’s CSS resets, frameworks, or wildcard selectors will style your elements unintentionally. Attach a Shadow DOM root instead.
1// content/ui-injector.js (ISOLATED world)
2function mountExtensionPanel() {
3 const host = document.createElement("div");
4 host.id = "mv3-ext-panel-host";
5 document.body.appendChild(host);
6
7 const shadow = host.attachShadow({ mode: "closed" });
8
9 const link = document.createElement("link");
10 link.rel = "stylesheet";
11 link.href = chrome.runtime.getURL("content/panel.css");
12 shadow.appendChild(link);
13
14 const container = document.createElement("div");
15 container.className = "panel-root";
16 shadow.appendChild(container);
17
18 return container; // hand off to your UI framework
19}
Execution context: Content script in ISOLATED world. chrome.runtime.getURL() requires the CSS file to be listed in web_accessible_resources with the correct matches entry. mode: "closed" prevents host-page JavaScript from reaching into the shadow root via element.shadowRoot. Firefox and Chrome both support closed shadow roots; Safari has historically been slower to adopt some Shadow DOM v1 features but has supported the core API since Safari 14.
eval(), new Function(string), and <script src="https://..."> injection are all blocked by the extension’s default CSP.chrome.tabs in content scripts: use chrome.runtime.sendMessage to ask the service worker.document_start runs before <head>: do not query elements that do not exist yet.world: 'MAIN' blocks chrome.*: you cannot use extension APIs inside a MAIN world function — proxy everything through the ISOLATED side.all_frames: true injects into every sub-frame: performance cost and cross-origin frame restrictions apply; see injecting content scripts into dynamic iframes for frameId targeting and the cross-origin limits.| Feature | Chrome / Edge | Firefox | Safari |
|---|---|---|---|
chrome.scripting.executeScript | Chrome 88+ | browser.scripting, FF 101+ | Safari 17+ |
world: 'MAIN' | Chrome 102+ | Firefox 128+ | Not supported |
run_at: document_start | Reliable | Reliable | Unreliable on heavy pages |
Shadow DOM mode: 'closed' | Full support | Full support | Safari 14+ |
match_about_blank | Supported | Supported | Partial |
all_frames in manifest | Supported | Supported | Supported |
Firefox carries forward some MV2 permission-handling behaviour: a content script can be granted to <all_urls> without a browser-action click, whereas on Chrome this requires a broad host permission declared at install time. Safari enforces per-site permission prompts that may prevent injection even with correct manifest declarations.
all_frames, frameId targeting, cross-origin limits.chrome.scripting.Target iframes in MV3 content scripts with all_frames, match_about_blank, and chrome.scripting frameId — plus cross-origin limits and timing for dynamically inserted frames.
Prevent prototype pollution, CSP violations, and world-boundary leaks in Manifest V3 content scripts — secure bridging, CustomEvent patterns, and MAIN world hardening.