Sandboxed iframes: Giving embedded content only the power it needs

Modern web pages almost always include third-party widgets, ads, or user-generated content. Each of those embeds is a potential attack surface: a malicious ad, a compromised widget script, or a craftily crafted user post can all do real damage to your site and your users. Content Security Policy helps by whitelisting specific origins, but CSP's protection is binary — a resource is either allowed in full or blocked entirely. There is no middle ground for content you want to show but don't fully trust.

Least privilege for embedded frames

The principle of least privilege offers a better approach: grant embedded content only the minimum capabilities needed for it to function. If a widget never opens a popup, disable window.open. If it doesn't use Flash, block plugins outright. The less you hand out, the less there is to abuse.

The iframe element is the natural starting point. Loading untrusted content in a frame isolates it from your page's DOM, local storage, and layout. But a framed document still has plenty of capabilities: autoplaying video, plugins, popups, and form submissions can all be triggered from inside the frame. The sandbox attribute tightens those restrictions by letting you specify exactly which privileges the framed content receives.

A practical example: Twitter's Tweet button

Consider Twitter's Tweet button, which can be embedded via an iframe with code like this:

<iframe src="https://platform.twitter.com/widgets/tweet_button.html"
        style="border: 0; width:130px; height:20px;"></iframe>

What does that widget actually need? It executes JavaScript from Twitter's servers to handle clicks, opens a popup containing a tweeting interface, submits a form, and accesses Twitter's cookies to associate the tweet with the correct account. That's the full scope — no plugins, no top-level navigation, no pointer lock. Remove everything else:

<iframe sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
    src="https://platform.twitter.com/widgets/tweet_button.html"
    style="border: 0; width:130px; height:20px;"></iframe>

With those four flags, the frame gets exactly the capabilities it needs and nothing more. The browser denies everything not explicitly granted.

How sandboxing works

Sandboxing uses a whitelist model. Start with all privileges revoked, then re-enable individual capabilities with flags on the sandbox attribute. An empty sandbox value produces a fully restricted document with these behaviors:

  • JavaScript does not execute — not from script tags, inline event handlers, or javascript: URLs. Content in noscript tags is displayed as if the user had disabled script.
  • The document is assigned a unique origin, so all same-origin checks fail. It cannot access cookies, DOM storage, IndexedDB, or any other data tied to an origin.
  • New windows and dialogs cannot be created via window.open or target="_blank".
  • Forms cannot be submitted.
  • Plugins do not load.
  • The document can navigate only itself. Setting window.top.location throws an exception; clicking a target="_top" link does nothing.
  • Automatic features — autofocused form elements, autoplaying videos — are blocked.
  • Pointer lock is unavailable.
  • The seamless attribute on iframes inside the sandboxed document is ignored.

That level of restriction makes a fully sandboxed frame very safe, but also mostly inert. Static content may work; anything interactive needs some permissions restored.

Available sandbox flags

Each restriction — except plugins — can be lifted with a flag. Sandboxed documents can never load plugins, because plugins are unsandboxed native code. The flags are:

  • allow-forms — permits form submission.
  • allow-popups — permits popups.
  • allow-pointer-lock — permits pointer lock.
  • allow-same-origin — lets the document keep its real origin, so content from https://example.com/ retains access to that origin's data.
  • allow-scripts — permits JavaScript execution and re-enables automatic features, since they become trivial to implement in script.
  • allow-top-navigation — lets the document navigate the top-level window.

That explains the Twitter example exactly: allow-scripts for the interaction logic, allow-popups for the tweet form's window, allow-forms so that form can be submitted, and allow-same-origin so Twitter's cookies remain readable and the user can log in.

One subtle point: sandbox flags propagate to any window or frame the sandboxed document opens. So even though the form in the Twitter case lives in a popup rather than the frame itself, allow-forms must still be on the frame's sandbox for the popup's form to submit.

The result is a widget that works exactly as intended, while plugins, top-level navigation, pointer lock, and every other privilege it never asked for remain firmly blocked. The risk of embedding third-party content drops significantly, without sacrificing functionality.

Why Sandbox Your Own Code?

Sandboxing untrusted third-party content is an obvious win: it lets you run code you don't control in a low-privilege environment. But there's a stronger case for applying the same treatment to code you *do* trust. If your application doesn't need plugins, why grant itself access to them? At best that privilege goes unused; at worst it becomes an entry point for an attacker. Every codebase has bugs, and a compromised application that runs with full origin privileges is far more damaging than one confined to a minimal set of capabilities.

You can push this further by decomposing your application into logical modules, each sandboxed with the narrowest privilege set that still lets it do its job. This is a well-established pattern in native code. Chrome, for instance, splits itself into a high-privilege browser process (with disk and network access) and many low-privilege renderer processes that parse untrusted content. A renderer never touches the disk; the browser process feeds it everything it needs to render a page. Even if an attacker corrupts a renderer, they've hit a dead end — any high-privilege operation has to be routed through the browser process. An attacker would need separate vulnerabilities in multiple components to do real damage, which dramatically reduces the odds of a successful exploit.

A Practical Example: Safe eval()

Sandboxed iframes and the postMessage API make it straightforward to bring this model to the web. Each piece of your application can live in its own sandboxed frame; the parent document brokers communication by posting messages and listening for replies. This containment limits the blast radius of any single exploit. It also has a side benefit: it forces you to define clean integration points, making it obvious where input and output validation matters. Here's a minimal toy example to show how the pieces fit.

Evalbox takes a string and evaluates it as JavaScript. On its own that's a security nightmare — arbitrary script execution puts every piece of data in the origin at risk. Executing the code inside a sandboxed frame makes it considerably safer. Starting from the inside out, the framed document is minimal: it listens for message events from its parent.

<!-- frame.html -->
<!DOCTYPE html>
<html>
    <head>
    <title>Evalbox's Frame</title>
    <script>
        window.addEventListener('message', function (e) {
        var mainWindow = e.source;
        var result = '';
        try {
            result = eval(e.data);
        } catch (e) {
            result = 'eval() threw an exception.';
        }
        mainWindow.postMessage(result, event.origin);
        });
    </script>
    </head>
</html>

In the event handler, the frame grabs the event's source attribute (the parent window) so it can send results back. It then passes the received data to eval(). The call is wrapped in a try block because operations that are banned inside a sandboxed frame often generate DOM exceptions; those are caught and reported as friendly error messages instead. The result is posted back up to the parent.

The parent side is equally simple. It builds a small UI with a textarea for code and a button to trigger execution, and pulls in frame.html via a sandboxed iframe with only script execution allowed:

<textarea id='code'></textarea>
<button id='safe'>eval() in a sandboxed frame.</button>
<iframe sandbox='allow-scripts'
        id='sandboxed'
        src='frame.html'></iframe>

Wiring up execution is a two-step process. First, the parent listens for response messages from the iframe and surfaces them with alert():

window.addEventListener('message',
    function (e) {
        // Sandboxed iframes which lack the 'allow-same-origin'
        // header have "null" rather than a valid origin. This means you still
        // have to be careful about accepting data via the messaging API you
        // create. Check that source, and validate those inputs!
        var frame = document.getElementById('sandboxed');
        if (e.origin === "null" &amp;&amp; e.source === frame.contentWindow)
        alert('Result: ' + e.data);
    });

Then it attaches a click handler to the button that grabs the current textarea contents and posts them to the frame:

function evaluate() {
    var frame = document.getElementById('sandboxed');
    var code = document.getElementById('code').value;
    // Note that we're sending the message to "*", rather than some specific
    // origin. Sandboxed iframes which lack the 'allow-same-origin' header
    // don't have an origin which you can target: you'll have to send to any
    // origin, which might alow some esoteric attacks. Validate your output!
    frame.contentWindow.postMessage(code, '*');
}

document.getElementById('safe').addEventListener('click', evaluate);

That's the entire pattern: a simple evaluation API where the executed code has no access to cookies, DOM storage, plugins, popups, or any of the other capabilities a sandboxed frame lacks. You can apply the same structure to your own monolithic code by splitting it into single-purpose components, each wrapped in a messaging API like the one above. The high-privilege parent acts as a controller and dispatcher, feeding each module only the information it needs and nothing more.

One caveat: be extremely careful when framing content from the same origin as the parent. If a page on https://example.com/ frames another same-origin page with a sandbox that includes both the allow-same-origin and allow-scripts flags, the framed page can reach up into the parent and strip the sandbox attribute entirely, defeating the protection.

The Sandbox Is Not a Silver Bullet

Sandboxing is available today across Firefox 17+, IE10+, and current Chrome builds (caniuse has an up-to-date support table). Applying the sandbox attribute to an iframe lets you grant the framed content precisely the privileges it needs — nothing more. That's a meaningful layer of risk reduction on top of what Content Security Policy already offers for third-party content.

Used on your own code, sandboxing is a powerful defense-in-depth technique. Decomposing a monolithic app into sandboxed services forces attackers to compromise both a specific frame and its controller before they can do serious damage. That's a considerably harder task, especially when the controller itself can be kept small enough to audit thoroughly.

Sandboxing is not, however, a complete security solution. You can't yet rely on every user's browser supporting it (unless you control the client environment, as in an enterprise deployment). And it doesn't eliminate the need for careful validation or robust application logic. But it is an excellent additional layer — one worth adding to your defenses.

Further Reading

  • "Privilege Separation in HTML5 Applications" is a paper that walks through the design of a small framework and applies it to three existing HTML5 applications.
  • Sandboxing becomes more flexible when combined with two newer iframe attributes: srcdoc lets you populate a frame without a separate HTTP request, and seamless lets styles flow into framed content. Browser support for both is currently thin (Chrome and WebKit nightlies), but the combination promises to be useful. For example, you could sandbox article comments like this:
    <iframe sandbox seamless
            srcdoc="<p>This is a user's comment!
                       It can't execute script!
                       Hooray for safety!</p>"></iframe>