Security

iframe sandbox for untrusted HTML, with a strict CSP

Render untrusted HTML safely with an iframe sandbox and a Content Security Policy: which tokens to allow, the pair to avoid, and a setup you can copy.

7 min read

To render untrusted HTML safely, put it in an iframe with an empty sandbox attribute, which blocks scripts, forms, popups and top-level navigation and gives the content an opaque origin. Then add a Content Security Policy at the top of that document so it cannot load anything from the network, and when the content really must run scripts, serve it from a separate origin. Each layer covers a gap the others leave open.

What counts as untrusted HTML

Any HTML your own code did not write: user posts and rich text, HTML emails, embedded widgets, documents converted to HTML and, more and more often, HTML written by an AI model or a coding agent. Treat it as hostile even when its author means well: a model can be steered by text it read, and a user can paste anything.

Inserted directly into your page, such HTML can:

  • run scripts with your origin’s cookies, storage and APIs (cross-site scripting);
  • load an image or a stylesheet from a remote server, which tells that server who viewed the content and when;
  • show a fake sign-in form, or send the whole tab to a phishing page;
  • cover your interface with its own buttons;
  • confuse your own code by shadowing DOM properties with element names (DOM clobbering).

The sandbox attribute, token by token

An iframe with sandbox and no value applies every restriction. Each token of the sandbox attribute lifts one of them:

Token What it allows For untrusted HTML
allow-scripts Running scripts Only on a separate origin
allow-same-origin Keeping the document’s real origin Avoid, except to read the DOM with scripts off
allow-forms Submitting forms Rarely needed
allow-popups window.open() and target="_blank" Rarely needed
allow-top-navigation Navigating the whole tab Avoid
allow-modals alert(), confirm(), print() Avoid
allow-downloads Downloading files Avoid

Start from the empty attribute and add a token only when a feature you need breaks without it. Without allow-same-origin, the document is treated as coming from a special origin that always fails the same-origin policy, which keeps it away from your cookies and storage.

The combination to avoid

MDN is explicit: when the framed document has the same origin as the embedding page, using both allow-scripts and allow-same-origin lets the document remove its own sandbox attribute, which leaves it no safer than no sandbox at all. The same page adds a second warning: a sandbox only protects content shown inside the iframe. If a viewer can open the content on its own, in a new tab, it runs without the sandbox, so it should also live on a separate origin.

allow-same-origin alone, with scripts off, has a legitimate use. MDN notes that a same-origin parent can then read and change the framed document’s DOM, while no script inside the frame runs. That is how an editor can outline and restyle the elements of an untrusted document without executing it.

srcdoc or a separate origin

There are two ways to load the content:

  • srcdoc puts the HTML inline, with no network request. Set it from JavaScript (frame.srcdoc = html) rather than writing it into the attribute by hand, which requires escaping & and quotes. Relative URLs in the content resolve against your page’s URL.
  • A separate origin, such as a dedicated domain for user content, serves each document at its own URL. It is the only reasonable place for content that runs scripts. Serve it with a Content-Security-Policy header that includes the sandbox directive, so the document stays sandboxed even when someone opens its URL directly.

Add a CSP that blocks the network

A sandbox with scripts off still lets HTML fetch resources: an <img> pointing to a tracking server, a background: url(...) in CSS, a web font. A Content Security Policy closes that. For HTML that should render with no network at all:

<meta http-equiv="Content-Security-Policy"
      content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:">

This policy allows inline styles and data: images and fonts, and nothing else: no script, no request to any server. What to know about a policy delivered in a <meta> tag, from MDN and the W3C CSP specification:

  • It does not apply to content that comes before it, so it must be the first thing in the document.
  • frame-ancestors, report-uri and sandbox are not supported in a meta tag; they need an HTTP header.
  • form-action and base-uri do not fall back to default-src. The sandbox already blocks forms; add form-action 'none' and base-uri 'none' if you want the policy complete on its own.
  • Several policies can only add restrictions. If the untrusted HTML brings its own meta policy, it can tighten yours, never loosen it.

Protect the page around the iframe

The iframe is half the job. The page that embeds it needs its own defenses:

  • A strict policy for your app, for example scripts only from your own origin (script-src 'self'), so that an injection elsewhere cannot run inline code.
  • Distrust the framed DOM. If your code reads the document through allow-same-origin, element names can shadow properties. An <img name="elementFromPoint"> replaces document.elementFromPoint on that document, and an input named parentElement inside a form replaces form.parentElement. Call methods through their prototype, for example Document.prototype.elementFromPoint.call(doc, x, y), and walk your own copy of the tree.
  • Validate messages. If the content runs scripts on a separate origin and talks to your page with postMessage, check event.origin and event.source on every message, then check its shape. MDN warns that skipping the sender check enables cross-site scripting.

A setup you can copy

const CSP =
  `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; ` +
  `style-src 'unsafe-inline'; img-src data:; font-src data:">`;

export function renderUntrusted(container, html) {
  const frame = document.createElement("iframe");
  frame.setAttribute("sandbox", ""); // every restriction on
  frame.title = "Preview";
  frame.srcdoc = CSP + html; // the policy comes first
  container.replaceChildren(frame);
}

And the checklist that goes with it:

  1. Empty sandbox by default; add one token at a time, with a reason.
  2. Never allow-scripts with allow-same-origin on content from your own origin.
  3. A policy with default-src 'none' as the first element of the framed document.
  4. Scripts only on a separate origin, served with the sandbox directive in its header.
  5. A strict policy on your own app.
  6. No trust in names read from the framed DOM, and a sender check on every message.

How agentappbuilder renders HTML written by agents

agentappbuilder shows prototypes that a coding agent writes as HTML, so it treats every frame as untrusted. Its security page describes the setup:

  • Each frame renders in an iframe with an empty sandbox attribute: no script, no form, no popup, no navigation of the page around it, an opaque origin.
  • While you edit a frame on the canvas, that frame renders with sandbox="allow-same-origin" and still no scripts, so the app can read and restyle its elements. The app reads it through its own DOM functions and its own copy of the tree, so an element named parentElement or elementFromPoint cannot mislead or freeze the editor. Copy as PNG uses the same setting.
  • Before rendering, the app puts the policy default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data: at the top of the HTML.
  • The app itself is served with a policy that only allows scripts from its own origin.

That is why the tips for good prototypes ask for inline CSS and data: images: scripts do not run, and remote images and fonts do not load. Rendering is one side of agent-written content; the other is what the text inside it can make an agent do, covered in prompt injection in agent tools, and what a leaked credential could reach, covered in giving an AI agent access with a scoped key.

None of these layers is enough alone. Together, a sandbox with scripts off, a policy with no network, and a parent page that trusts nothing it reads make untrusted HTML something you can display, not something that runs.

Frequently asked questions

Is an empty sandbox attribute enough to render untrusted HTML?

It blocks scripts, forms, popups and top navigation, and gives the content an opaque origin. It does not stop images, stylesheets or fonts from loading, so add a Content Security Policy that blocks the network, starting with default-src 'none'.

Why not use allow-scripts and allow-same-origin together?

When the framed document has the same origin as the page, the two tokens together let it remove its own sandbox attribute. MDN strongly discourages the combination for that reason.

Can I set a Content Security Policy with a meta tag in srcdoc?

Yes, a meta http-equiv Content-Security-Policy tag works for most directives. Put it first, because it does not apply to content before it, and note that frame-ancestors, report-uri and sandbox are not supported in a meta tag.

Try it on your own app

Tell Claude Code one sentence. It creates the project and gives you a link to see your screens.

Tell Claude Code:

Connect this project to agentappbuilder.com and show me its screens.