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 + insert | Sandboxed iframe | |
|---|---|---|
| Styles | leak into your page, and yours into it | fully isolated both ways |
| Layout | flows with your page | fixed box, you set the height |
| Security model | deny-list (must catch everything) | allow-list (off unless you allow it) |
Arbitrary CSS, full pages, <style> blocks | risky and messy | fine |
| Cost | cheap | a 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>, noonclick, nojavascript: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"andwindow.openare 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>| Token | Allows |
|---|---|
allow-scripts | JavaScript |
allow-same-origin | keeps the frame’s real origin instead of "null" |
allow-forms | form submission |
allow-popups | window.open, target="_blank" |
allow-popups-to-escape-sandbox | popups open without the sandbox |
allow-top-navigation-by-user-activation | may navigate the top page, but only after a click |
allow-top-navigation | may navigate the top page any time |
allow-modals | alert and friends |
allow-downloads | downloads |
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’ssrcdoc. 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
srcandsrcdocare set,srcdocwins.
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:
- Fixed height chosen by the author or the app. Simple and safe.
- Allow scripts in the frame (but not same-origin) and have a tiny script
postMessageits height to the parent. More moving parts, and now the frame runs code. - 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, sinceevent.originwill 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
srcdocframe withsandbox=""and no tokens. It never getsallow-scriptsorallow-same-origin. - The srcdoc document starts with a CSP meta tag based on
default-src 'none'andimg-src data: blob:, so the HTML can’t load anything from the network. referrerPolicy="no-referrer"and an explicitallowlist.- 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-originon 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.originfrom a sandboxed frame is"null". Checkevent.sourceinstead.- Always give iframes a
title. - Every frame is a full document. Lazy-load them.
Practice
- 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. - Try
allow-scripts allow-same-originwith a same-originsrc, and write a script inside that removes the frame’s own sandbox attribute throughparent.document. - Build auto-height with
allow-scripts+postMessage, verifyingevent.source. - Render 50 sandboxed frames and compare page load with and without
loading="lazy".
Sources worth reading
- MDN: iframe (the
sandbox,srcdocandallowsections) - web.dev: Play safely in sandboxed IFrames
- MDN: Content Security Policy, Permissions Policy
- HTML spec: Sandboxing