Isolation & sandboxing
Run untrusted JavaScript, widgets, ads, and user content safely with iframe sandboxing, postMessage checks, COOP and COEP.
- 01Lock down embedded contentChoose iframe sandbox tokens deliberately and avoid the dangerous same-origin script pair.
- 02Communicate across boundariesUse
postMessage,targetOrigin, origin checks, source checks, shape checks, andMessageChannelsafely. - 03Pick the right isolation layerCompare iframes, workers, cross-origin isolation, ShadowRealm, Node
vm, and SES-style lockdown.
Isolation before trust
Professional JavaScript often touches code or content your team did not write: analytics tags, ads, payment widgets, browser extensions, customer HTML, plugin scripts, and learner code in a playground. The safe question is not “do we trust it?” The safer question is “what boundary keeps it from reaching anything else?”
Isolation means running untrusted code or content behind a boundary that grants only the powers it needs, then communicating through a small validated channel instead of sharing the whole page.
This lesson builds on windows and frames, messaging and structured cloning, eval, and XSS. We will recap the same-origin policy from CORS, then add iframe sandboxing, message validation, cross-origin isolation headers, workers, ShadowRealm, and Node vm trade-offs.
A guest can work in a locked room without touching the rest of the house. Browser isolation runs risky code in a smaller place and passes checked messages through a small door.
- In real life: A separate locked room
- In JavaScript: A sandboxed iframe or process boundary
- In real life: A small door for messages
- In JavaScript: A narrow
postMessageorMessagePortAPI - In real life: Rules for what enters
- In JavaScript: Sandbox tokens and validation
Where the analogy stops: A real room has a physical lock. Software isolation still depends on browser behavior, added permissions, and careful message checks.
Boundaries and origins
The same-origin policy is the baseline boundary. Two documents are same-origin only when scheme, host, and port match. Same-origin documents can usually read each other; cross-origin documents usually cannot read DOM, storage, cookies, or variables. The CORS lesson is about network reads. Here the question is what a framed document or script can do once it is already on the page.
Third-party widgets and ads need isolation because they are still code in a user’s browser. A map widget should not read your CSRF token. A customer HTML preview should not reach your admin app. A plugin should not freeze the UI forever. A code editor, including this site’s own editor, should let examples run without giving them the completefrontend.com origin.
Use layers. Sandboxed iframes isolate documents. Workers isolate DOM access and main thread work. COOP and COEP isolate the browsing context group for powerful memory APIs. Message validation isolates intent. None of them excuses unsafe rendering, token storage mistakes, or the defenses in CSRF and tokens.
The iframe sandbox
BROWSER LABA bare sandbox attribute starts by removing powers: scripts, forms, popups, top navigation, pointer lock, downloads, modal dialogs, and the frame’s normal same-origin identity. Then you add back only the tokens the embed needs. Common tokens include allow-scripts, allow-forms, allow-popups, and allow-same-origin.
Never rely on sandbox="allow-scripts allow-same-origin" to isolate same-origin untrusted content. The framed page can run script while being treated as your origin. For same-origin content, that can let it remove its own sandbox attribute.
Pick a sandbox token set, then step through which browser powers are still blocked.
script
function describeSandbox(tokens, sameOriginContent) { const has = (name) => tokens.includes(name); return { scripts: has("allow-scripts") ? "allowed" : "blocked", forms: has("allow-forms") ? "allowed" : "blocked", popups: has("allow-popups") ? "allowed" : "blocked", origin: has("allow-same-origin") ? "real origin" : "opaque origin", topNavigation: has("allow-top-navigation") ? "allowed" : "blocked", canRemoveSandbox: sameOriginContent && has("allow-scripts") && has("allow-same-origin"), };} console.log(describeSandbox(tokens, true));The runnable snippet below creates a real sandboxed iframe in the lesson editor’s page. It allows scripts so the child can report back, but it never grants allow-same-origin. The child’s direct attempt to read the parent DOM is caught as a security error, and the message origin is the opaque string "null".
const frame = document.createElement("iframe");frame.sandbox = "allow-scripts";frame.srcdoc = `<!doctype html><script>let parentDom = "read";try { parent.document.body; } catch (error) { parentDom = "blocked:" + error.name; }let cookie = "";try { document.cookie = "lesson=1"; cookie = document.cookie || "(empty)"; }catch (error) { cookie = "blocked:" + error.name; }parent.postMessage({ lesson: "sandbox-probe", origin: self.origin, parentDom, cookie }, "*");</script><p>Sandboxed child</p>`; window.addEventListener("message", (event) => { if (event.source !== frame.contentWindow) return; if (!event.data || event.data.lesson !== "sandbox-probe") return; console.log("event.origin:", event.origin); console.log("child self.origin:", event.data.origin); console.log("parent DOM:", event.data.parentDom); console.log("cookie:", event.data.cookie);}); document.querySelector("#sandbox-output").append(frame);const tokens = ["allow-scripts"]; function describeSandbox(tokens, sameOriginContent) { const has = (name) => tokens.includes(name); return { scripts: has("allow-scripts") ? "allowed" : "blocked", forms: has("allow-forms") ? "allowed" : "blocked", popups: has("allow-popups") ? "allowed" : "blocked", origin: has("allow-same-origin") ? "real origin" : "opaque origin", topNavigation: has("allow-top-navigation") ? "allowed" : "blocked", canRemoveSandbox: sameOriginContent && has("allow-scripts") && has("allow-same-origin"), };} console.log(describeSandbox(tokens, true));opaque originwaitingwaitingThe real iframe reports from inside only when scripts are allowed. Without allow-same-origin, its origin remains opaque and parent DOM access is blocked.
allow-scripts or enable the dangerous same-origin pair. That warning is the browser enforcing the boundary.- Running JavaScript in a bare sandbox
- Submitting a form from the frame
- Opening a popup from the frame
- Navigating the top-level page
- Reading the parent page as same-origin
`allow-scripts` plus `allow-same-origin` on same-origin content
Sort each ability by the sandbox rule it depends on. Start with the assumption that a bare sandbox blocks it.
srcdoc is convenient for previews because the HTML lives in the attribute instead of a separate URL. A sandboxed srcdoc frame without allow-same-origin receives an opaque origin. The message event exposes that as "null", so source checks become especially important. A response header can apply a similar restriction with the CSP sandbox directive, but an iframe attribute is the usual tool for embeds you create in JavaScript.
This site’s editor case study
The CompleteFrontend editor runs learner code in a sandboxed preview iframe. In both src/components/editor/EditorApp.tsx and InlineEditor.tsx, the sandbox constant grants scripts, forms, popups, pointer lock, downloads, and modals, but not allow-same-origin.
const SANDBOX = "allow-scripts allow-modals allow-forms allow-popups " + "allow-popups-to-escape-sandbox allow-pointer-lock allow-downloads"; // CompleteFrontend deliberately does not include allow-same-origin.The runtime inside public/editor-runtime.js explains why: the preview frame has an opaque origin, so learner code cannot read this site’s sign-in or storage. The runtime then provides in-memory storage, project-file fetches, worker creation, and postMessage-based console reporting.
/* * The frame has an opaque origin (no allow-same-origin), so learner code can't reach the * site's storage or sign-in. This file fills in what that origin lacks: localStorage, * sessionStorage, and cookies kept in memory, fetch() of project files, workers made from * project files, and links between project pages. */Safe postMessage handlers
STEP THROUGHIsolation still needs communication. postMessage copies data with the structured clone algorithm, so the receiver gets data, not the sender’s live object. Sending code must use a specific targetOrigin for sensitive data. Use "*" only when the target has an opaque origin, such as a sandboxed srcdoc frame, and never for secrets.
Receiving code needs three gates: validate event.origin, validate event.source against the exact window or port you stored, and validate the shape of event.data. The step-through uses a simulated MessageEvent-like object so you can see every accepted and rejected path deterministically.
Choose an incoming message, then step through the origin, source, and shape gates.
script
const trustedSource = paymentFrame; function isPaymentReady(data) { return data && data.type === "payment:ready" && typeof data.checkoutId === "string";} function onMessage(event) { if (!allowedOrigins.has(event.origin)) return "reject: origin"; if (event.source !== trustedSource) return "reject: source"; if (!isPaymentReady(event.data)) return "reject: shape"; return "accept: " + event.data.checkoutId;} console.log(onMessage(incomingEvent));For ongoing private conversations, transfer one end of a MessageChannel. The public message event is then just the handshake; the two ports form a private line between the parent and that frame or worker.
const channel = new MessageChannel();channel.port1.onmessage = (event) => { console.log("private reply", event.data.type);}; trustedWindow.postMessage( { lesson: "isolation", type: "connect-port" }, "*", [channel.port2],); channel.port1.postMessage({ type: "private:hello" });Use a control to send a message.
Both frames have the opaque origin null, so the handler also checks the exact event.source. The MessageChannel button transfers a private port after the public handshake.
targetOrigin: "*" only because these sandboxed srcdoc frames have opaque origins. It sends no secrets.Cross-origin isolation
Spectre showed that timing and speculative execution can leak information across boundaries. Browsers responded by restricting SharedArrayBuffer and some high-resolution measurement APIs unless a page opts into stronger process and resource isolation. The runtime check is self.crossOriginIsolated.
| Piece | Example | Job |
|---|---|---|
| COOP | Cross-Origin-Opener-Policy: same-origin | Separates this browsing context group from cross-origin openers. |
| COEP | Cross-Origin-Embedder-Policy: require-corp or credentialless | Requires embedded subresources to opt in or be loaded without credentials. |
| CORP | Cross-Origin-Resource-Policy: same-origin or cross-origin | A resource header that tells isolated pages whether they may embed it. |
| Runtime check | self.crossOriginIsolated | True only when the page and its environment satisfy the isolation rules. |
| What comes back | SharedArrayBuffer and high-precision APIs | Browsers restrict these after Spectre unless cross-origin isolation is active. |
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Resource-Policy: same-originconsole.log(self.crossOriginIsolated);console.log(typeof SharedArrayBuffer);console.log(typeof performance.measureUserAgentSpecificMemory);Verified in the installed Chrome 154 for this task: a normal data: page had self.crossOriginIsolated === false, SharedArrayBuffer undefined, and performance.measureUserAgentSpecificMemory undefined. The same browser reported all three available after a routed page sent COOP: same-origin and COEP: require-corp.
COEP can break third-party images, scripts, iframes, and analytics that do not send CORS or CORP headers. credentialless helps some cases by loading without credentials, but payment widgets, ads, and social embeds may still need a non-isolated page or a different integration.
Workers, ShadowRealm, and Node vm
A Web Worker is another browser boundary. It runs in a separate global scope and cannot touch the DOM. That makes it useful for parsing, image processing, and data-only plugin work. It is not a full security boundary by itself: a worker can still consume CPU, make allowed network requests, and send bad messages unless the parent validates them.
// main pageconst worker = new Worker("plugin-worker.js", { type: "module" });worker.postMessage({ type: "render", text: userText });worker.onmessage = (event) => { preview.textContent = event.data.text;}; // plugin-worker.jsself.onmessage = (event) => { // There is no document or DOM in a worker global scope. self.postMessage({ text: String(event.data.text).toUpperCase() });};ShadowRealm is a TC39 proposal for creating a fresh ECMAScript realm with its own global object, built-ins, and intrinsics. Its proposed API includes new ShadowRealm(), realm.evaluate(sourceText), and realm.importValue(specifier, bindingName). Checked on 2026-09-26: the TC39 proposal is at Stage 2.7, and Chrome 154 in this environment reports typeof ShadowRealm === "undefined".
if (typeof ShadowRealm === "function") { const realm = new ShadowRealm(); const twice = await realm.importValue("./plugin.js", "twice"); console.log(twice(21)); console.log(realm.evaluate("Array.prototype === globalThis.Array.prototype"));} else { console.log("ShadowRealm is not available in this browser yet");}A fresh realm gives isolated built-ins, but it does not automatically solve side channels, infinite loops, memory exhaustion, host APIs you expose, or browser bugs. Treat it as a future language tool, not a replacement for iframe, worker, process, or permission boundaries.
Node’s vm module has a similar naming trap. The official Node.js documentation says: “The node:vm module is not a security mechanism. Do not use it to run untrusted code.” Use OS processes, containers, service isolation, or hardened capability systems when hostile code is in scope.
import vm from "node:vm"; const context = vm.createContext({ answer: 41 });vm.runInContext("answer += 1", context);console.log(context.answer); // Node.js docs: the node:vm module is not a security mechanism.// Do not use it to run untrusted code.SES and Hardened JavaScript take a capability-oriented approach: freeze or tame the platform, then run code in compartments with only the powers you pass in. That can be a good plugin-system direction, but it is a design commitment, not a line of code you add after a page is already trusting everything.
import "ses";lockdown(); const compartment = new Compartment({ console });compartment.evaluate("console.log(Array.prototype.map.call([1, 2], x => x * 2))");| Boundary | What it isolates | Good fit | Limit |
|---|---|---|---|
| Sandboxed iframe | Runs a whole document with browser-enforced permissions | Untrusted widgets, ads, HTML previews, and code playgrounds | Strongest browser primitive here; still validate messages and tokens |
| Web Worker | Runs script off the main thread with no DOM access | CPU-heavy parsing, image work, plugin logic that only needs data | No DOM access, but same-origin network/storage rules and CPU abuse still matter |
| ShadowRealm | Fresh ECMAScript realm with separate globals and intrinsics | Future plugin-style code when available | Not a security boundary for side channels, host powers, or infinite loops |
Node vm | Separate V8 context inside one Node process | Trusted tooling, tests, or templates | Node docs explicitly say it is not for untrusted code |
| SES lockdown | Freezes and tames intrinsics, then runs code in compartments | Capability-oriented plugin systems | Promising but requires a disciplined ecosystem and threat model |
Real-world patterns
Use sandboxed iframes for ads, comments rendered as HTML, user-created previews, payment widgets, and code playgrounds. Use workers for data-only CPU work. Use cross-origin isolation only on pages that need SharedArrayBuffer, high-precision memory measurement, or libraries that depend on them. Keep XSS defenses from the XSS lesson; sandboxing is defense in depth, not a sanitizer.
- For a code playground, use
sandbox="allow-scripts"plus a message bridge, not same-origin access. - For payment widgets, use the provider’s origin, a specific
targetOrigin, and a strict message schema. - For customer HTML, sanitize first, then preview in a sandbox that does not share your origin.
- For plugin data processing, start with a worker and a narrow command protocol before considering stronger process isolation.
Common misconceptions
“A sandboxed iframe is automatically safe.”
It depends on tokens and messages. allow-scripts allow-same-origin on same-origin content can defeat the sandbox, and a sloppy message handler can still turn copied data into a bug.
“postMessage with star is always fine because the receiver checks origin.”
The sender still controls delivery. Use a specific targetOrigin for secrets. Use * only for opaque origins and non-secret messages.
“Workers are security sandboxes.”
Workers lack DOM access, which is useful, but they are still code you run. They can loop, allocate memory, and send hostile messages.
“ShadowRealm will make arbitrary plugin code safe.”
It isolates ECMAScript globals. It does not by itself meter CPU, remove side channels, or decide which host powers your app exposes.
Practice exercises
Read the token list and predict the origin string a sandboxed frame would report.
const tokens = ["allow-scripts"];
const origin = tokens.includes("allow-same-origin") ? "real origin" : "null";
console.log(origin);The token list has allow-scripts but not allow-same-origin, so the frame keeps an opaque origin reported as "null".
Trace the validator and decide whether it accepts or rejects the message.
const trustedOrigin = "https://pay.example";
const trustedSource = "frame-a";
const event = { origin: "https://pay.example", source: "frame-b", data: { type: "payment:ready", checkoutId: "co_42" } };
const shapeOK = event.data.type === "payment:ready" && typeof event.data.checkoutId === "string";
const ok = event.origin === trustedOrigin && event.source === trustedSource && shapeOK;
console.log(ok ? "accept" : "reject");The event comes from frame-b, not the stored frame-a, so the handler prints reject.
A code editor needs to run JavaScript examples but must not expose the site’s origin. Which single sandbox token is required?
Use allow-scripts so JavaScript can run, but leave out allow-same-origin so the frame stays opaque.
Which two response headers are the standard browser route to crossOriginIsolated?
const headers = ["Cross-Origin-Opener-Policy", "Cross-Origin-Embedder-Policy"];
console.log(headers.join(" + "));Use Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy together; commonly COOP: same-origin plus COEP: require-corp.
In review, name the token pair that should not be used as the isolation boundary for same-origin untrusted content.
const tokens = ["allow-scripts", "allow-same-origin"];
console.log(tokens.includes("allow-scripts") && tokens.includes("allow-same-origin"));Flag allow-scripts plus allow-same-origin on same-origin untrusted content. It can let the child remove its own sandbox.
Check your understanding
Question 1 of 7What is the plain definition of isolation in this lesson?
Choose an answer to see the explanation.
Question 2 of 7What does this sandbox-origin check print?
Read the code, then predictconst tokens = ["allow-scripts"]; const origin = tokens.includes("allow-same-origin") ? "real origin" : "null"; console.log(origin);Choose an answer to see the explanation.
Question 3 of 7What does the message validator print for a spoofed source?
Read the code, then predictconst trustedOrigin = "https://pay.example"; const trustedSource = "frame-a"; const event = { origin: "https://pay.example", source: "frame-b", data: { type: "payment:ready", checkoutId: "co_42" } }; const shapeOK = event.data.type === "payment:ready" && typeof event.data.checkoutId === "string"; const ok = event.origin === trustedOrigin && event.source === trustedSource && shapeOK; console.log(ok ? "accept" : "reject");Choose an answer to see the explanation.
Question 4 of 7When is
targetOrigin: "*"acceptable?Choose an answer to see the explanation.
Question 5 of 7Which header pair is the usual route to
self.crossOriginIsolated === true?Choose an answer to see the explanation.
Question 6 of 7What is accurate about ShadowRealm on 2026-09-26?
Choose an answer to see the explanation.
Question 7 of 7Which statement about Node's
vmmodule matches the official docs?Choose an answer to see the explanation.
Key takeaways
- Start sandboxed iframes locked down, then add only the tokens the embed actually needs.
- Never combine
allow-scriptsandallow-same-originas a same-origin security boundary. - Use
postMessagewith specifictargetOriginwhen possible, then validate origin, source, and shape on receipt. - Cross-origin isolation is a header opt-in for powerful memory APIs, not a general widget sandbox.
- Workers, ShadowRealm, Node
vm, and SES compartments solve different pieces; choose based on the threat model.
Isolation lets untrusted code do useful work through a narrow, checked opening instead of handing it the whole page.
Next in this module, Web Crypto shows how to use browser cryptography APIs without inventing your own primitives. Keep the same habit: choose the browser tool with the boundary you need.