Injecting Content Scripts into Dynamic Iframes

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.

Published June 19, 2026 Updated June 19, 2026 9 min read
Table of Contents

Your content script runs fine on the top-level page, but the widget you need to modify lives inside an iframe — one that the host page injects dynamically after your script has already finished initialising. The declarative all_frames: true flag only covers frames that exist at page load; a frame inserted by a SPA router or a lazy-loaded ad slot arrives after the injection window has closed. The result is silence: no error, no injection, just a frame your extension cannot see. This guide covers every iframe injection option in MV3. It is part of Content Scripts & DOM Injection.

Root cause: how frame injection timing works in MV3

The browser’s content script injection pipeline runs once per navigation event on a matching document. For run_at: document_idle on the top frame, that fires during the initial page load. Any <iframe> added to the DOM after that event has already fired is treated as a new navigation in a sub-frame — it gets its own lifecycle events, but your declarative registration only matches frames whose navigation was in-flight when the content script system evaluated the match patterns.

Programmatic injection through chrome.scripting.executeScript is not tied to page load timing. You can call it at any point while the tab is open, target a specific frame by its frameId, and inject then. The hard limits are cross-origin isolation and the fact that frameId is not stable — it is assigned per-navigation and can change if the frame navigates.

Top frame load
  │
  ├── document_start  ← earliest declarative injection point
  ├── document_end
  ├── document_idle   ← default declarative injection; your content script runs
  │
  └── SPA event / lazy content injects <iframe>
        │
        └── iframe navigation (new frameId assigned)
              │
              ├── iframe document_start
              └── iframe document_idle  ← declarative all_frames hits here
                                         but only if URL matched at navigation time

Step 1: Use all_frames for known frame patterns

If you know the iframe’s URL pattern at install time, declare all_frames: true in manifest.json. The browser injects into every frame whose navigation URL matches — both the top frame and any sub-frames, static or dynamic.

 1{
 2  "manifest_version": 3,
 3  "name": "Frame Inspector",
 4  "permissions": ["storage", "scripting"],
 5  "host_permissions": ["https://*.example.com/*"],
 6  "content_scripts": [
 7    {
 8      "matches": ["https://*.example.com/*"],
 9      "js": ["content/inspector.js"],
10      "run_at": "document_idle",
11      "all_frames": true          // injects into every matching sub-frame
12    }
13  ]
14}

Execution context: manifest.json — evaluated at install time. all_frames: true covers dynamically added frames, but only when the frame’s own navigation URL matches the matches pattern. A dynamically inserted <iframe src="https://example.com/widget"> triggers a navigation that the content script system sees. A frame whose src is on a different origin (e.g., https://cdn.thirdparty.com/) will not match and will not be injected — that is a security boundary, not a bug.

Step 2: Cover about:blank and about:srcdoc frames

Frames with src="about:blank" or written with document.write() inherit their parent’s origin but are not matched by URL patterns. Set match_about_blank: true to reach them.

 1{
 2  "content_scripts": [
 3    {
 4      "matches": ["https://*.example.com/*"],
 5      "js": ["content/blank-frame-handler.js"],
 6      "run_at": "document_idle",
 7      "all_frames": true,
 8      "match_about_blank": true   // also injects into about:blank frames whose parent matches
 9    }
10  ]
11}

Execution context: manifest.json. match_about_blank only applies when the parent frame’s URL matches — the browser uses the parent’s origin to determine permission. Firefox supports match_about_blank from Firefox 52+. Safari added support in Safari 15.4. about:srcdoc frames follow the same rule; Chrome 93+ extends match_about_blank semantics to them.

Step 3: Target a specific frame with frameId

For dynamic frames where you cannot know the URL at install time — a frame spawned by a user interaction, a chat widget injected by a third-party script on a first-party origin, or a frame whose URL is generated at runtime — use chrome.scripting.executeScript with an explicit frameId.

First, discover the frameId from within the top-level content script:

 1// content/frame-watcher.ts  (runs in top frame, ISOLATED world)
 2const observer = new MutationObserver((mutations) => {
 3  for (const mutation of mutations) {
 4    for (const node of mutation.addedNodes) {
 5      if (
 6        node instanceof HTMLIFrameElement &&
 7        node.src.startsWith("https://example.com/")
 8      ) {
 9        node.addEventListener("load", () => {
10          // Ask the service worker to inject into this frame by src URL
11          chrome.runtime.sendMessage({
12            type: "INJECT_INTO_FRAME",
13            frameSrc: node.src,
14          });
15        });
16      }
17    }
18  }
19});
20
21observer.observe(document.documentElement, { childList: true, subtree: true });

Execution context: Content script in ISOLATED world of the top-level frame. MutationObserver fires synchronously when nodes are added. Attaching the load listener before sending the message ensures the frame’s document is ready before the service worker attempts injection. Firefox and Chrome behave identically. Safari supports MutationObserver but may queue callbacks at lower priority during page scroll — critical logic should not depend on immediate callback timing.

Then in the service worker, resolve the frame’s frameId and inject:

 1// service-worker.js
 2chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
 3  if (msg.type !== "INJECT_INTO_FRAME" || !sender.tab?.id) return;
 4
 5  const tabId = sender.tab.id;
 6  const frameSrc = msg.frameSrc as string;
 7
 8  // Resolve all frames in the tab, find the one matching the src
 9  chrome.webNavigation.getAllFrames({ tabId })
10    .then((frames) => {
11      if (!frames) return;
12      const target = frames.find((f) => f.url === frameSrc);
13      if (!target) return;
14
15      return chrome.scripting.executeScript({
16        target: { tabId, frameIds: [target.frameId] },
17        files: ["content/frame-script.js"],
18      });
19    })
20    .catch((err) => console.error("Frame injection failed:", err));
21
22  return false; // no async sendResponse needed
23});

Execution context: Service worker. chrome.webNavigation.getAllFrames() requires the webNavigation permission in the manifest. frameId values are stable for the lifetime of a single navigation — if the frame navigates again, you must re-discover the id. Firefox supports browser.webNavigation.getAllFrames() with identical parameters. Safari added browser.webNavigation support in Safari 16.

Step 4: Add webNavigation permission for frame discovery

1{
2  "permissions": ["scripting", "webNavigation"],
3  "host_permissions": ["https://*.example.com/*"]
4}

Execution context: manifest.json. webNavigation is a non-host permission that does not generate an install-time warning visible to the user, but it does appear in the Chrome Web Store listing’s permission description. Firefox and Safari accept the same permission key.

Cross-origin frame limits

This is the hard wall. The browser enforces origin isolation at the frame boundary: you cannot inject into a cross-origin frame, period, regardless of what permissions you declare. If https://page.example.com embeds <iframe src="https://ads.thirdparty.net/">, your extension cannot inject into that iframe unless you also declare https://ads.thirdparty.net/* as a host permission — and even then, you are injecting your own isolated world into the third-party’s frame, not bridging across origins from the parent frame.

Practical rules:

  • Same-origin frames (same scheme + host + port): injectable with matching host permission.
  • about:blank frames inheriting a same-origin parent: injectable with match_about_blank: true.
  • Cross-origin frames: require their own host permission entry or <all_urls>.
  • Sandboxed frames (sandbox attribute without allow-same-origin): always opaque origin, never injectable.

Cross-browser variation

  • Chrome / Edge: Full support for all_frames, match_about_blank, frameIds in executeScript, webNavigation.getAllFrames. match_about_blank extended to about:srcdoc in Chrome 93.
  • Firefox: all_frames and match_about_blank supported. frameIds in browser.scripting.executeScript supported from Firefox 101. webNavigation.getAllFrames supported but may return stale frame data on rapid SPA navigations.
  • Safari: all_frames supported. match_about_blank supported from Safari 15.4. frameIds in browser.scripting.executeScript supported from Safari 17. browser.webNavigation.getAllFrames available from Safari 16; test carefully as frame enumeration on dynamically modified pages can lag by one event loop tick.

Verification

  1. Open Chrome DevTools on the host page. In the Elements panel, select the injected iframe. In the Console panel, use the context switcher (top-left dropdown) to switch to the iframe’s context. Run window.__extInjected — if your content script sets this flag on load, its presence confirms injection succeeded.
  2. Add console.log("[frame-script] running in", location.href) at the top of frame-script.js. Check the Console and filter by the frame’s URL to confirm timing.
  3. For the MutationObserver path: temporarily add debugger inside the observer callback and verify the iframe’s load event fires before the service worker sends executeScript.
  4. To confirm cross-origin blocking: attempt to target a cross-origin frame without its host permission and check the DevTools console for Error: Cannot access a chrome-extension:// URL of different extension or NotAllowedError.

FAQ

Does all_frames: true cover iframes added after page load by JavaScript?

Yes, but only for the frame’s own navigation. When JavaScript inserts <iframe src="https://example.com/widget">, the browser starts a navigation in that frame. If example.com/widget matches your matches pattern, the content script is injected at the appropriate run_at phase. If the frame’s src is dynamically rewritten without a navigation (e.g., using srcdoc mutation), declarative injection may not fire — use programmatic injection in that case.

Can I detect when a new frame is added from the service worker directly?

Not directly via a push event. The chrome.webNavigation.onCommitted event fires for each frame navigation (including sub-frames) and includes frameId and parentFrameId. Listen to it in the service worker and filter by tabId and url to programmatically inject immediately after a known frame commits.

 1// service-worker.js
 2chrome.webNavigation.onCommitted.addListener(
 3  (details) => {
 4    if (details.frameId === 0) return; // skip top frame
 5    chrome.scripting.executeScript({
 6      target: { tabId: details.tabId, frameIds: [details.frameId] },
 7      files: ["content/frame-script.js"],
 8    }).catch(() => {}); // cross-origin frames throw — swallow gracefully
 9  },
10  { url: [{ hostSuffix: "example.com" }] }
11);

Execution context: Service worker. webNavigation.onCommitted fires before document_idle, so this approach is earlier than waiting for a content-script message. Requires webNavigation permission. Firefox supports the same event. Safari supports it from Safari 16.

What happens if the frame navigates after I inject?

The injected script is destroyed along with the old document. The new navigation starts a fresh document in the same frame with the same frameId (the id is tied to the frame element, not the document). If you are using declarative injection with all_frames: true, the browser re-evaluates the new navigation URL automatically. If you are using programmatic injection, you must listen for the navigation again and re-inject.

Why does my frame script fail with NotAllowedError even with correct host permissions?

Three common causes: (1) The frame is cross-origin and you do not have a host permission for that specific origin. (2) The frame’s URL has not committed yet — load event on the iframe element fires after document_idle in the frame, but the frame may briefly be at about:blank during transitions. (3) On Safari, per-site permissions were denied by the user at install time — check the extension’s permission status in Safari Preferences.

Other MV3 Architecture & Extension Lifecycle Resources