Isolating Untrusted Remotes with Iframes #
Module Federation gives a remote your origin, your cookies and your DOM — this page replaces that with a sandboxed iframe and a typed message contract for the remotes you do not fully trust.
Isolation is not the opposite of composition. The remote still renders inside your layout, still reacts to your theme, and still participates in your routing. What it loses is the ability to read a session token, walk the host’s DOM, or patch a global. This is the last control described in securing federated remotes, and the one to reach for when the answer to “would hostile code here matter?” is yes.
Prerequisites #
- The isolated remote served from a different origin to the host — a separate subdomain is enough, and is what makes the realm boundary real.
- A host able to set
Content-Security-Policy: frame-srcfor that origin, and the remote able to setframe-ancestorsfor yours. - A defined message contract: what the host sends in, what the remote sends back.
- ResizeObserver support for height synchronisation, which is universal in current browsers.
Step 1 — Put the remote on its own origin #
An iframe pointing at the same origin as the host is not isolated: contentWindow.document is fully reachable, and so is everything in it.
# The isolated remote is served from its own subdomain
server {
server_name partner.example.com;
root /srv/partner-widget;
# Only our host may frame this, and it may not frame anything else.
add_header Content-Security-Policy "frame-ancestors https://app.example.com; default-src 'self';" always;
add_header X-Frame-Options "ALLOW-FROM https://app.example.com" always;
}
A distinct subdomain gives a distinct origin, which is all the same-origin policy needs. Do not set document.domain to relax it — that reintroduces exactly the access you are trying to remove, and it is deprecated for this reason.
Step 2 — Sandbox the frame with the narrowest set of capabilities #
The sandbox attribute starts from zero and you add back only what the remote demonstrably needs.
<iframe
src="https://partner.example.com/widget"
title="Partner recommendations"
sandbox="allow-scripts allow-forms"
referrerpolicy="no-referrer"
loading="lazy"
></iframe>
The omissions are the design. Leaving out allow-same-origin means the frame runs in an opaque origin with no cookies, no localStorage and no IndexedDB — it cannot store or read anything belonging to your users. Leaving out allow-top-navigation means it cannot redirect the page out from under you. Leaving out allow-popups means it cannot open windows.
Never combine allow-scripts with allow-same-origin for untrusted content. Together they let the framed document reach out and remove its own sandbox attribute, which defeats the whole mechanism.
Add allow for powerful features explicitly rather than by default:
<iframe
src="https://partner.example.com/widget"
sandbox="allow-scripts"
allow="camera 'none'; microphone 'none'; geolocation 'none'; payment 'none'"
></iframe>
Step 3 — Define the message contract before writing any code #
postMessage is the only channel, so it deserves the same care as a public API. Version it, type it, and enumerate the messages.
// contracts/partner-widget.ts — imported by host and remote
export type HostToWidget =
| { v: 1; type: 'init'; locale: string; theme: 'light' | 'dark' }
| { v: 1; type: 'context'; productId: string }
| { v: 1; type: 'theme'; theme: 'light' | 'dark' };
export type WidgetToHost =
| { v: 1; type: 'ready' }
| { v: 1; type: 'resize'; height: number }
| { v: 1; type: 'select'; productId: string }
| { v: 1; type: 'error'; message: string };
Notice what is absent. No tokens, no user identifiers, no callbacks, no DOM references. Everything that crosses is structured-cloneable data, and every field is one the remote genuinely needs. That constraint is doing security work: an isolated remote that receives a session token is no longer isolated in any meaningful sense.
Version the protocol from the first message. A widget you do not control will be updated on someone else’s schedule, and v is what lets both sides handle a mismatch deliberately.
Step 4 — Validate every message on both sides #
Never trust event.data, and never trust that a message came from where you expect.
// src/PartnerWidget.tsx — host side
const REMOTE_ORIGIN = 'https://partner.example.com';
useEffect(() => {
function onMessage(event: MessageEvent) {
// Origin check first: a sandboxed frame without allow-same-origin
// posts from "null", so compare against the frame's contentWindow too.
if (event.origin !== REMOTE_ORIGIN && event.origin !== 'null') return;
if (event.source !== frameRef.current?.contentWindow) return;
const msg = WidgetToHostSchema.safeParse(event.data);
if (!msg.success) {
reportSecurityEvent('widget-invalid-message', { issues: msg.error.issues });
return;
}
handle(msg.data);
}
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, []);
The event.source check matters as much as the origin check. Any frame or window on the page can post a message, and origin alone does not tell you it came from your iframe.
When sending, always name the target origin — never '*':
frameRef.current?.contentWindow?.postMessage(
{ v: 1, type: 'context', productId } satisfies HostToWidget,
REMOTE_ORIGIN, // never '*': that broadcasts to whatever is loaded
);
Step 5 — Synchronise height without letting the frame resize the page #
An iframe has no intrinsic content height, so the remote has to report it. That report is untrusted input and needs bounding.
// remote side — report the real content height
const ro = new ResizeObserver(([entry]) => {
parent.postMessage(
{ v: 1, type: 'resize', height: Math.ceil(entry.contentRect.height) },
'https://app.example.com',
);
});
ro.observe(document.documentElement);
// host side — clamp it, so a hostile or buggy widget cannot take over the page
function handleResize(height: number) {
const clamped = Math.min(Math.max(height, 120), 1200);
setFrameHeight(clamped);
}
Without the clamp, a single message claiming a height of 500,000 pixels produces a page that scrolls forever. Reserve the minimum height up front as well, for the same layout-shift reasons described in lazy loading remotes below the fold.
Step 6 — Pass theme through, not styles #
The frame cannot inherit your stylesheet, which is the point. What it can receive is the small set of values it needs to look native.
// host: send tokens, never CSS
frame.contentWindow?.postMessage({
v: 1, type: 'init', locale: 'en-GB', theme: currentTheme,
}, REMOTE_ORIGIN);
Send semantic tokens — a theme name, an accent colour, a density setting — rather than raw CSS text. Sending CSS to an untrusted frame is harmless to you but couples the remote to your internal class names, and it will break the first time you refactor. The token approach mirrors the design token discipline used for trusted remotes.
Step 7 — Handle the failure modes an iframe adds #
Isolation introduces failure modes federation does not have, and each needs an explicit answer.
// src/PartnerWidget.tsx — a widget that never says "ready" is a failed widget
useEffect(() => {
const timer = setTimeout(() => {
if (!readyRef.current) {
reportEvent('widget-never-ready', { remote: 'partner' });
setFailed(true); // collapse the slot, keep the page
}
}, 8000);
return () => clearTimeout(timer);
}, []);
A frame that never loads produces no error event you can rely on, so a readiness timeout is the only robust detector. Treat the timeout as a failure, collapse the slot, and report it — the same containment behaviour an error boundary gives a trusted remote.
Two more worth planning for. Keyboard focus can enter the frame and, if the widget handles it poorly, become difficult to escape; test the tab order with a keyboard before shipping. And the framed document has its own navigation history in some browsers, which can make the back button behave unexpectedly — keeping the widget on a single view avoids the problem entirely.
Verification #
- The realm is genuinely separate: in the console, run
document.querySelector('iframe').contentWindow.document. It must throw a cross-origin error. - Storage is unavailable: inside the frame,
localStorageshould throw or be empty. If it works,allow-same-originis present and the sandbox is not doing its job. - Cookies do not cross: confirm the frame’s requests carry no host cookies in the Network panel.
- Messages are rejected: from the console,
postMessagea malformed payload to the host and confirm it is ignored and reported. - Height is clamped: have the frame report a height of 999999 and confirm the host caps it.
- Framing is restricted: load the widget URL inside a page on another origin and confirm
frame-ancestorsblocks it.
Troubleshooting #
Symptom: the widget renders but cannot store anything.
Diagnosis: this is the sandbox working — without allow-same-origin the frame has an opaque origin and no storage. Fix: if the widget genuinely needs to persist state, have it send the state to the host and let the host store it, so the data stays under your control.
Symptom: messages from the frame have origin "null".
Diagnosis: expected for a sandboxed frame without allow-same-origin. Fix: accept 'null' but pair it with the event.source identity check, as in step 4 — the source comparison is what actually authenticates the sender.
Symptom: the page grows a second scrollbar.
Diagnosis: the frame’s reported height lags its content, or the clamp is lower than the real content height. Fix: set overflow: hidden on the frame element and let the reported height drive it; if content genuinely exceeds the maximum, let the widget scroll internally.
Symptom: the widget works locally and is blocked in production.
Diagnosis: the host’s frame-src directive does not list the widget origin, or the widget’s frame-ancestors does not list the host. Fix: both headers are required and they point at each other — add the origin to the CSP as described in content security policy for Module Federation.