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.

What isolation actually costs and buys The iframe trades runtime dependency sharing and direct calls for a genuine realm boundary the remote cannot reach across. Composition without a shared realm Property Federated remote Sandboxed iframe Origin the host's its own Cookies and storage full access none Host DOM fully reachable unreachable Shared dependencies negotiated at runtime its own copies Communication direct function calls postMessage only Failure blast radius whole page one frame
The fourth row is the real cost: an isolated remote ships its own framework copy, which is the price of the first three rows.

Prerequisites #

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 #

The postMessage contract in both directions Only structured data crosses the boundary, and nothing that crosses is a credential, a callback or a DOM reference. Host Iframe Widget Host state 1. render frame, sandboxed 2. ready 3. init: locale + theme 4. context: productId 5. resize: height 6. select: productId
Look at what never appears in this exchange: no token, no user id, no function. That absence is the isolation.

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 #

Failure modes an iframe adds, and their responses Isolation removes some risks and introduces others, all of which are handled at the host boundary rather than trusted to the widget. The isolated widget is not working. What kind of failure? Never sent ready Collapse and report Timeout after 8 s No load event to rely on Sent an invalid message Ignore and report Schema rejected it Never act on unparsed data Reported an absurd height Clamp and continue Bound to a sane range Page layout protected
Every response here keeps the host page intact — which is the property isolation was bought for in the first place.

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 #

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.