Injecting into iframes and All Frames

Target sub-frames with chrome.scripting — allFrames, frameIds, matchOriginAsFallback for about:blank and data: frames, and how to talk to a specific frame.

Published September 18, 2026 Updated September 18, 2026 8 min read
Table of Contents

Your content script works on the page and does nothing inside the embedded checkout iframe, the comment widget, or the about:blank frame a script created two seconds after load. Each of those is a different frame with its own origin rules, and allFrames: true covers only the first kind. Getting injection right in sub-frames means knowing which frames exist, which ones you have permission for, and which ones have no URL to match against at all. This guide is part of scripting API dynamic injection.

The frame tree you are actually injecting into

Four kinds of frame on one pageA top frame containing a same-origin iframe, a cross-origin iframe, a srcdoc frame and a script-created about:blank frame, each with different matching behaviour.Top framehttps://shop.exampleSame-origin iframe/cart — matches normallyCross-origin iframehttps://pay.other — needs its own hostand the two with no real URLsrcdoc frameinherits the parent originabout:blank framecreated by page scriptmatchOriginAsFallbackthe flag that reaches both
Only the first two have a URL your match patterns can see; the last two inherit their origin from the creator.

Step-by-step

1. Inject into every frame of a tab

1await chrome.scripting.executeScript({
2  target: { tabId, allFrames: true },
3  files: ["content/scan.js"],
4});

Execution context: the service worker. The script runs once per frame the extension has host access to; frames on origins you lack permission for are skipped silently, and the result array simply has fewer entries than the page has frames.

2. Reach srcdoc and about:blank frames

A frame created by script has no URL to match. matchOriginAsFallback tells the browser to match against the origin the frame inherited instead.

1{
2  "content_scripts": [{
3    "matches": ["https://shop.example/*"],
4    "js": ["content/scan.js"],
5    "all_frames": true,
6    "match_origin_as_fallback": true    // reaches about:blank, srcdoc and blob: frames
7  }]
8}

Execution context: parsed at install. Without this flag, an about:blank iframe created by shop.example is invisible to a script matching https://shop.example/*, because its URL is about:blank and matches nothing.

1// The same flag on a programmatic injection:
2await chrome.scripting.registerContentScripts([{
3  id: "scan-all",
4  matches: ["https://shop.example/*"],
5  js: ["content/scan.js"],
6  allFrames: true,
7  matchOriginAsFallback: true,
8  runAt: "document_start",
9}]);

Execution context: the service worker. Registration is the durable form of the same thing — see registering content scripts at runtime.

3. Find the frame you actually want

Injecting everywhere is wasteful when you need one frame. chrome.webNavigation.getAllFrames gives you the tree, including parent relationships.

 1async function findPaymentFrame(tabId) {
 2  const frames = await chrome.webNavigation.getAllFrames({ tabId });
 3  return frames.find((f) => f.url.startsWith("https://pay.other/"))?.frameId ?? null;
 4}
 5
 6const frameId = await findPaymentFrame(tabId);
 7if (frameId !== null) {
 8  await chrome.scripting.executeScript({
 9    target: { tabId, frameIds: [frameId] },
10    func: () => document.querySelector("#total")?.textContent,
11  });
12}

Execution context: the service worker. getAllFrames needs the webNavigation permission; without it, enumerate frames by injecting into allFrames and having each frame report its own window.location.href back.

4. Message one frame, not all of them

chrome.tabs.sendMessage broadcasts to every frame’s content script unless you name one.

1// Broadcast — every frame's listener runs, and only the first reply is delivered.
2await chrome.tabs.sendMessage(tabId, { type: "scan" });
3
4// Targeted — exactly one frame.
5await chrome.tabs.sendMessage(tabId, { type: "scan" }, { frameId });

Execution context: the service worker. On the receiving side, sender.frameId tells a content script which frame it is in — 0 is always the top frame. The broadcast pattern is expanded in broadcasting messages to all tabs.

5. Guard against running in the wrong frame

A script that expects to own the document will misbehave when it also runs in a 1×1 tracking iframe. Make the frame check the first thing it does.

1// content/scan.js
2if (window.top !== window.self && document.body?.clientHeight < 40) {
3  // Tiny sub-frame — almost certainly not the content we care about.
4} else {
5  start();
6}

Execution context: the content script’s isolated world in each frame. window.top !== window.self is readable even cross-origin; reading anything from window.top is not, and will throw.

Choosing a frame targetA decision tree: whether to inject into all frames, a single known frame id, or only the top frame.Does the feature need content from more than the top document?NoTop frame onlyomit allFramesframeIds: [0]explicit is clearerYes, one known widgetFind the frame idwebNavigation.getAllFramesframeIds: [id]one injectionYes, anywhere on the pageallFrames: trueplus a size guardmatchOriginAsFallbackfor srcdoc / about:blank
Defaulting to allFrames is the common mistake — most extensions want the top frame and one known widget.

Frames that appear after you looked

executeScript with allFrames: true is a snapshot. A single-page application that mounts a payment widget three seconds after load produces a frame your injection never saw, and the feature appears to work on a fast connection and fail on a slow one — the classic shape of a race nobody can reproduce.

There are three ways to catch late frames, and they trade off differently.

A standing registration is the cleanest: the browser applies it to every frame that navigates, including ones created later. The cost is that it applies to every matching page whether the feature is in use or not.

webNavigation.onCommitted lets you react per frame, which is precise but needs the webNavigation permission and a listener that survives eviction:

1chrome.webNavigation.onCommitted.addListener(async ({ tabId, frameId, url }) => {
2  if (frameId === 0) return;                       // the main frame is handled elsewhere
3  if (!url.startsWith("https://pay.other/")) return;
4  await chrome.scripting.executeScript({ target: { tabId, frameIds: [frameId] }, files: ["content/widget.js"] });
5}, { url: [{ hostEquals: "pay.other" }] });

Execution context: the service worker, registered at the top level. The URL filter is not decoration — without it the listener wakes the worker on every navigation in every tab, which is a measurable battery cost on a busy browser.

A MutationObserver in the top frame is the permission-free option: the already-injected top-frame script watches for new iframes and asks the worker to inject into them. It cannot see cross-origin frame contents, but it can see the element appear, which is all that is needed to trigger the request.

 1// content/top.js, running in the main frame
 2new MutationObserver((records) => {
 3  for (const r of records) {
 4    for (const n of r.addedNodes) {
 5      if (n.tagName === "IFRAME" && n.src.startsWith("https://pay.other/")) {
 6        chrome.runtime.sendMessage({ type: "frame:appeared", src: n.src });
 7      }
 8    }
 9  }
10}).observe(document.documentElement, { childList: true, subtree: true });

Execution context: the content script’s isolated world in the top frame. Reading n.src is same-origin-safe because it is an attribute of the host page’s element, not of the framed document.

When each strategy catches a late frameA page loads, a widget iframe is inserted three seconds later, and a one-shot allFrames injection misses it while a registration and an onCommitted listener do not.navigation+5 sDocument loadsframes: 1executeSc…snapshot t…App bootsstill one frameWidget if…frames: 2Registration / onCommitt…late frame coveredone-shot injection ends herelate frame gets its script
The one-shot injection is correct and simply too early — nothing about it will ever catch the later frame.

Cross-browser variation

  • Chrome / Edge: matchOriginAsFallback is supported from Chrome 119 in the manifest and in registerContentScripts. Frame ids are stable for the lifetime of the frame and 0 is always the main frame.
  • Firefox: supports all_frames and frame-targeted messaging. match_origin_as_fallback support arrived later, and Firefox’s handling of srcdoc frames has differed across versions — verify rather than assume.
  • Safari: all_frames works; frame enumeration through webNavigation is more limited, and injection into about:blank frames is unreliable. Where a Safari build needs sub-frame data, prefer having the top frame’s script read it through the DOM when same-origin.
  • All three: you can never inject into a cross-origin frame without a host permission for that origin, no matter what the parent page’s permission is.

Verification

  1. Enumerate the frames the browser thinks exist:
1(await chrome.webNavigation.getAllFrames({ tabId }))
2  .map((f) => `${f.frameId} ${f.parentFrameId} ${f.url}`);
3// ["0 -1 https://shop.example/cart", "3 0 https://pay.other/widget", "7 0 about:blank"]

Execution context: the service worker console. A frame listed here that your script did not reach is either cross-origin without permission or a URL-less frame needing matchOriginAsFallback.

  1. Inject a marker into all frames and count the results:
1const results = await chrome.scripting.executeScript({
2  target: { tabId, allFrames: true },
3  func: () => location.href,
4});
5results.map((r) => [r.frameId, r.result]);

Execution context: the service worker console. Compare the length of this array with the frame list from step 1 — the difference is exactly the frames you were denied.

  1. Open DevTools on the page, use the frame selector at the top of the Console, and confirm your script’s globals exist in the frame you expected.

FAQ

Why does my script run twice on one page?

Because it ran in the top frame and in a same-origin iframe. Either add a frame guard, or target frameIds: [0] when only the top document matters.

Can a content script in an iframe talk to one in the top frame directly?

Not directly across origins. Route it through the service worker, which can address both by frameId — the pattern in implementing background messaging between popup and service worker.

Does allFrames include frames created after injection?

No. executeScript is a snapshot of the frames present at that moment. A standing registration does apply to new frames as they navigate, which is another reason to prefer registration for anything ongoing.

Other Core APIs & Cross-Browser Data Management Resources