← Writing
The Editorial · Engineering

Iframes: a page inside a page

Issue 007Jul 11, 202619 min read

The YouTube embed, the Stripe card field, the CAPTCHA checkbox — all iframes: complete web pages living inside yours, each with its own document, origin, and JavaScript world. This issue is the full tour: how the same-origin wall decides what you can touch, how postMessage crosses it safely, what sandbox actually switches off, and the attacks — clickjacking above all — that only exist because framing exists.

The issue

You've been using iframes all day without thinking about them. The YouTube video embedded in a blog post. The card-number field on a checkout page. The reCAPTCHA checkbox. The Google Map on a restaurant site. The ad in the sidebar. Every one of them is an <iframe>: a complete, independent web page rendered inside another one. This issue is everything I'd want a working engineer to know about them — what they actually are, the APIs on both sides of the wall, and the security model, which is most of the story, because nearly every iframe feature exists either to let two pages cooperate or to stop them from attacking each other.

A page inside a page

The tag itself looks unremarkable — <iframe src="…"> — but what the browser builds for it is not. An iframe creates a nested browsing context: a full page with its own document, its own window, its own URL and navigation history, its own cookies (for its origin, not yours), its own CSS, and its own JavaScript world. The parent page lays it out like a replaced element — a rectangle with a width and a height, no different from an <img> — but inside that rectangle an entire second page is running.

parent — https://app.exampleone window · one document · one originiframe — https://pay.exampleits own window · document · origin
Fig 1An iframe is a rectangle in the parent's layout, but a complete page on the inside — its own document, window, and origin. When the origins differ, the dashed line is a wall.AI-generated figure

The isolation runs in both directions and it is total for anything that isn't explicitly shared. The parent's CSS never styles the frame's content. The frame's JavaScript globals aren't the parent's. An uncaught exception in the frame doesn't touch the parent's scripts. Each side can crash, redirect, or rebuild itself without the other noticing — unless they choose to talk, using the channels we'll get to shortly.

the everyday embedHTML
<!-- The iframe you meet most often: an embed with an explicit size,
     lazy loading, and only the permissions it actually needs. -->
<iframe
  src="https://www.youtube-nocookie.com/embed/VIDEO_ID"
  width="560" height="315"
  title="Talk: how the event loop works"
  loading="lazy"
  referrerpolicy="strict-origin-when-cross-origin"
  allow="fullscreen; picture-in-picture"
></iframe>

<!-- srcdoc: no URL at all — the attribute IS the document.
     Pairs beautifully with sandbox for untrusted HTML. -->
<iframe sandbox srcdoc="<p>Rendered from a string.</p>"></iframe>

Same origin or not: the question that decides everything

Every capability an iframe has hangs on one comparison: does the frame's origin — scheme, host, and port, all three — match the parent's? The same-origin policy is the web's core security rule, and frames are where you feel it most. Same-origin frames are transparent: each side can reach straight into the other's DOM as if it were its own. Cross-origin frames are opaque: the handle still exists, but almost every property on it throws.

both sides of the wallJavaScript
const frame = document.querySelector("iframe");

// SAME-ORIGIN: the wall is down. Both sides can touch each other.
const doc = frame.contentDocument;            // the frame's document
doc.querySelector("h1").textContent = "Hello from the parent";
frame.contentWindow.someFunctionInTheFrame(); // call its code directly

// …and the frame can reach back up:
window.parent.document.title = "Hello from the child";
window.top;          // the outermost window, however deep you're nested
window.frameElement; // the actual <iframe> element hosting you

// CROSS-ORIGIN: every DOM access above throws a SecurityError.
// The handle survives, but it's opaque — here's ALL you can do with it:
frame.contentWindow.postMessage(data, "https://widget.example");
frame.contentWindow.location = "https://widget.example/next"; // navigate (write-only)
frame.contentWindow.length;  // how many subframes it has — and that's about it

Talking across the wall: postMessage

Cross-origin pages that need to cooperate — your checkout page and Stripe's card field, your article and its comments widget — get exactly one sanctioned channel: window.postMessage. It's the same message-passing model as a Web Worker: the payload crosses as a structured clone (objects, arrays, blobs — no functions, no DOM nodes), and each side listens for message events. What's different from workers is that here both ends are potentially hostile strangers, which is why the API forces origins into your hands.

parentapp.exampleiframewidget.examplepostMessage(data, origin)postMessage(reply, origin)send: name the target origin · receive: check event.origin
Fig 2postMessage is the only door in the cross-origin wall, and it has a rule on each side: the sender names the exact origin it's addressing, and the receiver verifies event.origin before trusting the data.AI-generated figure
parent.js / widget.jsJavaScript
// ── parent.js — talking to a cross-origin widget ──────────────
const frame = document.querySelector("iframe");

// SEND: always name the exact origin you intend to reach. If the
// frame has been navigated elsewhere, the message is simply dropped.
frame.contentWindow.postMessage(
  { type: "init", theme: "dark" },
  "https://widget.example",   // never "*" when the data matters
);

// RECEIVE: always check where a message came from. Any page that
// holds a reference to your window can send you one.
window.addEventListener("message", (event) => {
  if (event.origin !== "https://widget.example") return; // strangers: drop
  if (event.data?.type !== "resize") return;             // validate shape

  const height = Number(event.data.height);
  if (Number.isFinite(height)) frame.style.height = height + "px";
});

// ── widget.js — inside the iframe ─────────────────────────────
window.parent.postMessage(
  { type: "resize", height: 420 },
  "https://app.example",      // the parent's origin, named explicitly
);

sandbox: default-deny for embedded pages

The sandbox attribute flips the trust model. An iframe without it can run scripts, submit forms, open popups, and — the nasty one — navigate your top-level page away. Add sandbox with no value and everything is stripped: no scripts, no forms, no popups, no top navigation, no downloads, no plugins, and the content is forced into a unique opaque origin, so it isn't even same-origin with its own server anymore. Then you add back only what the content genuinely needs, one token at a time.

sandbox, from locked to looseHTML
<!-- Everything off. The gold standard for untrusted user HTML:
     no scripts, no forms, no popups, unique opaque origin. -->
<iframe sandbox srcdoc="<b>User-written content</b> renders safely here."></iframe>

<!-- Re-enable exactly what a widget needs, nothing more. -->
<iframe
  src="https://third-party.example/widget"
  sandbox="allow-scripts allow-forms allow-popups"
></iframe>

<!-- ⚠ The footgun. On content from YOUR OWN origin, this pair is
     self-defeating: same-origin + scripts means the frame can reach
     up and remove its own sandbox attribute, then reload unsandboxed.

<iframe src="/user-content.html"
        sandbox="allow-scripts allow-same-origin"></iframe>
-->

That last combination deserves the spelling-out. allow-same-origin restores the content's real origin; allow-scripts lets it run code. If the framed page is same-origin with you, those two together mean its scripts can walk up through window.parent, find its own <iframe> element, and call removeAttribute("sandbox") — the sandbox becomes a polite suggestion. For genuinely cross-origin embeds (a YouTube player, say) the pair is normal and safe, because the same-origin wall still stands between the frame and you. The rule of thumb: the more the framed content is yours — user uploads on your domain are the classic case — the less you can afford that pair.

Clickjacking: the attack framing made possible

Now the attacks. The oldest and most iframe-specific is clickjacking: an attacker's page loads your site in an iframe, sets the frame to opacity: 0, and positions it so that one of your real, live buttons sits exactly on top of a decoy. The victim believes they're clicking "claim free prize"; the click actually lands on "transfer funds" — or "delete account", or a camera-permission prompt — inside your page, carrying whatever session the framed page has.

evil.example — what the user seesClaim free prizebank.example iframe — opacity: 0Transfer fundsone click, two pages — the invisible one on top receives it
Fig 3Clickjacking: the victim site is framed invisibly above a decoy. The user aims at one button and hits another — on a page they can't see, with a session that may be live.AI-generated figure

The old defense was frame-busting JavaScript — if (top !== self) top.location = self.location — and it lost the arms race years ago: an attacker who sandboxes your frame with allow-scripts but without allow-top-navigation has neutered your buster with one attribute. The real defense is declarative: a response header that tells the browser who may frame you at all. The browser enforces it before your page even renders.

response headers — who may frame this pageHTTP
# Legacy but universally supported. Two values only.
X-Frame-Options: DENY          # nobody may frame this page
X-Frame-Options: SAMEORIGIN    # only my own origin may

# The modern version. Supersedes X-Frame-Options when both are present,
# and unlike it, can name specific partner origins.
Content-Security-Policy: frame-ancestors 'none';
Content-Security-Policy: frame-ancestors 'self' https://partner.example;

# And the OTHER direction — what your own page is allowed to embed:
Content-Security-Policy: frame-src https://js.stripe.com https://www.youtube-nocookie.com;

Ship both headers: frame-ancestors for every current browser, X-Frame-Options for stragglers. The default posture for any page with logged-in state should be frame-ancestors 'none' — loosen it only for pages that are meant to be embedded. One honest footnote: browsers' third-party cookie phase-out has blunted many clickjacking setups, because the framed page often isn't logged in anymore inside a cross-site frame. Blunted is not dead — the header is still mandatory.

The other direction: protecting yourself from what you embed

Framing cuts both ways, and the second threat model is the embed attacking you. Three controls stack here. CSP frame-src allowlists which origins your page may frame at all — so an injected <iframe> pointing somewhere hostile never loads. sandbox strips capabilities, as above. And the allow attribute is Permissions Policy at the frame boundary: camera, microphone, geolocation, payment, and friends are disabled inside cross-origin frames by default, and stay disabled unless you delegate them explicitly — allow="camera; microphone" is you co-signing for the frame's permission prompts.

Storage is where the ground has shifted most in the last few years. An embedded frame used to see the same cookies as a full tab on its own site — one login to a social network followed you into every widget on the web, which is precisely how like-button tracking worked. Browsers have ended that: third-party cookies are blocked or dying everywhere, and storage (localStorage, IndexedDB, even caches) is partitioned — an iframe of widget.example inside your site gets a different storage bucket than the same widget inside someone else's. A cross-site cookie now has to opt in with the Partitioned attribute (CHIPS) to exist at all, and a frame that genuinely needs its first-party cookies must ask the user via the Storage Access API. If you build embedded widgets: assume you are logged out inside a frame, and design the handshake around it.

Two more, briefly. Since Spectre, browsers put cross-origin iframes in their own OS process where they can (site isolation), so a malicious frame can't read your memory even with a speculative-execution exploit — you get this for free. And the credentialless attribute loads a frame with fresh, empty credentials, which is what lets pages that need SharedArrayBuffer (cross-origin-isolated pages) still embed third-party content.

The cost: every frame is a whole page

The security model is the reason to respect iframes; performance is the reason to ration them. Each one is a complete document — its own HTML parse, style and layout pass, JavaScript heap, and often its own process. An embedded YouTube player pulls in roughly a megabyte of script before anyone presses play; ad-heavy pages routinely carry dozens of frames and pay for each. Two habits cover most of it: loading="lazy" on anything below the fold, and the facade pattern for heavyweight embeds — render a static thumbnail with a play button, and only swap in the real iframe on click. The lite-youtube-embed component is the canonical example, and the technique generalizes to maps, chat widgets, and comment sections.

When an iframe is the right tool

After all the warnings, it's worth saying plainly: the iframe is one of the best security tools the platform has. Payment providers build on it — the Stripe card field is an iframe precisely so the card number is typed into their origin, never touching your DOM, which is what keeps most of PCI compliance off your plate. Email clients render messages inside sandboxed frames because an email is hostile HTML by definition. CAPTCHAs, OAuth prompts, and payment confirmation dialogs use frames so their UI can't be spoofed by the page hosting them. And rendering user-generated HTML with srcdoc plus an empty sandbox turns your scariest feature into a boring one.

One way to summarize this whole issue: an iframe is the browser putting a page inside a page with the browser standing between them as referee — the same-origin policy, sandbox, and Permissions Policy are the referee's rulebook. Issue 008 looks at what happens when you take the referee away: the WebView, a browser engine embedded inside a native app, where the host holds all the power and the trust model turns inside out.

All issues