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.
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
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.
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.
Cross-browser variation
- Chrome / Edge:
matchOriginAsFallbackis supported from Chrome 119 in the manifest and inregisterContentScripts. Frame ids are stable for the lifetime of the frame and0is always the main frame. - Firefox: supports
all_framesand frame-targeted messaging.match_origin_as_fallbacksupport arrived later, and Firefox’s handling ofsrcdocframes has differed across versions — verify rather than assume. - Safari:
all_framesworks; frame enumeration throughwebNavigationis more limited, and injection intoabout:blankframes 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
- 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.
- 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.
- 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.
Related
- Registering content scripts at runtime — standing registrations that catch new frames.
- Injecting content scripts into dynamic iframes — the lifecycle side of the same problem.
- Passing arguments to injected functions — getting data into a per-frame injection.
- Scripting API dynamic injection — the parent guide to injection in MV3.