completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

Isolation & sandboxing

Run untrusted JavaScript, widgets, ads, and user content safely with iframe sandboxing, postMessage checks, COOP and COEP.

By the end, you can
  • 01
    Lock down a framed pagePick only the sandbox tokens an iframe needs, and never rely on allow-scripts plus allow-same-origin.
  • 02
    Check messages from a frameSend with a real targetOrigin, then check the origin, the source, and the shape of every message. Use a MessageChannel for a private line.
  • 03
    Pick the right toolSay what iframes, workers, cross-origin isolation, ShadowRealm, Node vm, and SES each keep apart, and what they don't.

Keep untrusted code apart

Many web pages run code or show content that their own team did not write. Here are some common examples:

  • analytics scripts and ads from other companies
  • payment widgets, like a card form from a payment company
  • HTML that customers wrote, shown in a preview
  • plugins that other people write for your app
  • code that learners type into a playground, like the editor on this site

We call this code untrusted: you did not write it, so you can't be sure what it will do. It may have a bug, or it may try to do harm. So don't ask "do we trust it?" Ask "what stops it from reaching the rest of the page?"

Plain definition

Isolation means running untrusted code or content in its own separate space. That space gives it only the powers it needs. It talks to your page only through a small channel, and your page checks every message that comes through.

A sandbox is one way to build that separate space. The word comes from a children's sandbox: play happens inside it, and the sand stays inside it.

This lesson builds on windows and frames, messaging and structured cloning, eval, and XSS. You'll learn these tools, in this order:

  • origins, the browser's basic rule for what pages may read (a short review of the CORS lesson)
  • the sandbox attribute on an iframe
  • safe checks for messages sent with postMessage
  • the COOP and COEP headers, which turn on cross-origin isolation
  • workers, ShadowRealm, and Node's vm module, and what each one can and can't keep apart
Real-life analogyA locked room for a guest worker

You hire a guest to do a job in your house. You don't give them every key. You put them in one room with the tools they need, and lock the door. They pass notes to you through a small hatch. You read each note before you do what it says.

In real life: A separate locked room
In JavaScript: A sandboxed iframe, a worker, or a separate process
In real life: A small hatch in the door for notes
In JavaScript: postMessage or a MessagePort
In real life: Rules about what the guest may use
In JavaScript: Sandbox tokens, like allow-scripts
In real life: Reading each note before you act on it
In JavaScript: Checking each message before you use it

Origins and boundaries

The browser already has one basic rule that keeps pages apart. It is called the same-origin policy. An origin is made of three parts of a URL:

  • the scheme, like https
  • the host, like shop.example
  • the port, like 8443 (for https, the port is 443 when the URL doesn't show one)

Two pages are same-origin only when all three parts match. The path, like /cart, is not part of the origin.

Two URLs share an origin only when scheme, host, and port matchPop out in the code editor (opens in a new tab)JavaScript
const page = new URL("https://shop.example/cart");
const help = new URL("https://shop.example/help");
const otherPort = new URL("https://shop.example:8443/cart");
console.log(page.origin === help.origin);
console.log(page.origin === otherPort.origin);

Line 1 makes a URL for the cart page. Line 2 keeps the same scheme, host, and port, but changes the path to /help. So line 4 prints true. Line 3 changes the port to 8443. So line 5 prints false.

What does the rule allow? Pages from the same origin can usually read each other's page content (the DOM) and storage, like localStorage. Cookies have their own extra rules: their domain and path, and the HttpOnly flag, which hides a cookie from JavaScript. Pages from different origins usually can't read each other's DOM or storage.

The CORS lesson was about reading data over the network. This lesson asks a different question: what can a frame or a script do once it is already on your page?

Code from other companies still runs in your user's browser. That is why it needs a boundary. Here is what each kind of code should not be able to do:

  • A map widget should not read your CSRF token, the secret your forms send to prove a request is real.
  • A preview of customer HTML should not reach your admin pages.
  • A plugin should not freeze the page.
  • A code editor, like the one on this site, should run examples without giving them the completefrontend.com origin.
Use more than one layer

Each tool in this lesson keeps a different thing apart:

  • A sandboxed iframe keeps a whole page apart from yours.
  • A worker keeps code away from the DOM and off the main thread.
  • COOP and COEP keep your page apart from other sites, so that powerful memory tools can be turned on.
  • Message checks make sure only the right messages change your page.

None of them replaces safe HTML rendering, careful token storage, or the defenses in CSRF and tokens.

The iframe sandbox

BROWSER LAB

An iframe shows one web page inside another. Add the sandbox attribute to it, with no value, and the browser takes powers away from the framed page. A bare sandbox like that blocks all of these:

  • running scripts
  • sending forms
  • opening popups
  • sending the whole browser tab to a new address (called top navigation)
  • pointer lock, downloads, and dialogs like alert()
  • the page's normal origin

That last one matters most. Instead of its real origin, the framed page gets an opaque origin: a special origin that matches no other origin. Message events show it as the text "null".

Then you give back only the powers the framed page needs. Each power has its own word, called a token. You list the tokens in the sandbox attribute. Common tokens are allow-scripts, allow-forms, allow-popups, and allow-same-origin.

Allowing scripts does not allow formsPop out in the code editor (opens in a new tab)JavaScript
const tokens = ["allow-scripts"];
console.log(tokens.includes("allow-scripts"));
console.log(tokens.includes("allow-forms"));

Line 1 lists one token, allow-scripts. Line 2 asks if that token is in the list, and prints true. Line 3 asks about allow-forms, and prints false. So the frame can run JavaScript, but it still can't send forms. Add allow-forms only if the frame really needs to send a form.

Left: an iframe with a bare sandbox. Scripts are blocked and forms are blocked. Right: an iframe with sandbox allow-scripts. Scripts are allowed, but forms are still blocked.sandbox=""iframescripts: blockedforms: blockedstarts lockedsandbox="allow-scripts"iframescripts: allowedforms: blockedone power back
A bare sandbox blocks everything. The allow-scripts token gives back one power: running JavaScript. Forms stay blocked until you add allow-forms too.

The allow-same-origin token needs care. It lets the framed page keep its own real origin instead of the opaque one. It does not give a page from another site your origin.

The danger comes when the framed page is from your own origin. Then allow-scripts plus allow-same-origin lets its scripts reach the iframe element in your page. They can remove the sandbox attribute and reload without it. To stay safe, do one of these:

  • serve untrusted content from a different origin, or
  • leave out one of the two tokens.
Never use this pair as your protection

Never rely on sandbox="allow-scripts allow-same-origin" to protect your page from untrusted content that comes from your own origin.

Left: an iframe with only allow-scripts has the origin "null", so it cannot read the parent page. Right: an iframe with allow-scripts and allow-same-origin, showing content from your own origin, keeps the real origin and can remove its own sandbox.iframeallow-scriptsorigin "null"parent page blockedsandbox holdsiframeallow-scriptsallow-same-originyour real origincan remove sandboxsandbox gone
With only allow-scripts, the frame's origin is "null" and the parent page is out of reach. Add allow-same-origin to content from your own origin, and the frame can take its own sandbox off.

Popups and top navigation also need their own tokens. A popup opened from a sandboxed frame gets the same sandbox rules. The allow-popups-to-escape-sandbox token lets a popup open without them.

In the step-through below, pick a set of tokens. Then step through and watch which powers each token gives back.

Step through the sandbox tokens
Step 0 of 7Ready
Your turn: follow the blue line

Pick a list of tokens. Then step through to see which powers stay blocked.

Running in
  1. script
Next: line 1
 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));
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
Pick a list of tokens
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

The next snippet makes a real sandboxed iframe inside the lesson editor's page. It allows scripts, so the framed page can report back. It never allows allow-same-origin. Here is what happens, line by line:

  • Lines 1 to 3 make the iframe, give it only allow-scripts, and put a small page inside it with srcdoc.
  • Line 5 tries to read the parent page. The browser stops it with a SecurityError.
  • Lines 6 to 8 try to set a cookie. That is blocked too, because an opaque origin has no cookies.
  • Line 9 sends a report to the parent page with postMessage.
  • Line 13 ignores any message that doesn't come from this frame.
  • Lines 15 to 18 print the report. Both origins print as null.
Make a sandboxed iframe and read its reportPop out in the code editor (opens in a new tab)JavaScript
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);

In the playground, turn tokens on and off. A real iframe reports what it can do each time.

Sandbox playground: turn tokens on and off
Token checkerJavaScript
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));
Real iframeallow-scripts
scriptsallowed
formsblocked
popupsblocked
origin it reportsopaque origin
reading the parent pagewaiting
setting a cookiewaiting
Try it yourself

The real iframe can report back only when scripts are allowed. Without allow-same-origin, its origin stays opaque, and reading the parent page is blocked.

Chrome may print a sandbox warning in the console when you turn off allow-scripts or turn on the unsafe pair. That warning is the browser enforcing the sandbox.
Allowed, still blocked, or unsafe?
  • Running JavaScript in the frame
  • Sending a form from the frame
  • Opening a popup from the frame
  • Sending the whole tab to a new address
  • Reading the parent page's DOM
  • `allow-scripts` plus `allow-same-origin` on content from your own origin
Try it yourself
0 of 6 correct

A widget frame has sandbox="allow-scripts allow-forms allow-popups". Sort each power: allowed by a token in that list, or still blocked? One card is the unsafe pair.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

The srcdoc attribute is handy for previews. It holds the HTML of the framed page right in the attribute, so you don't need a separate URL. A sandboxed srcdoc frame without allow-same-origin gets an opaque origin. Its messages show the origin as "null". Many frames can share that same "null", so you must also check which frame sent a message.

A server can apply the same rules to a whole page with the CSP sandbox header. For a frame you make in JavaScript, the sandbox attribute is the usual tool.

How this site's editor uses a sandbox

The CompleteFrontend editor runs learner code in a sandboxed preview iframe. Two files set it up: src/components/editor/EditorApp.tsx and InlineEditor.tsx. Both use the same list of tokens. It allows scripts, forms, popups, pointer lock, downloads, and dialogs. It does not allow allow-same-origin.

The editor's token list leaves out allow-same-originJavaScript
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.

A comment in public/editor-runtime.js explains why. The preview frame has an opaque origin, so learner code can't reach this site's storage or sign-in. That file then adds back safe stand-ins for what the frame lost:

  • localStorage, sessionStorage, and cookies that live only in memory
  • fetch() for files in your project
  • workers made from files in your project
  • console output, sent to the editor with postMessage
The editor's runtime explains the opaque originJavaScript
/* * 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 message handlers

STEP THROUGH

An isolated frame still needs to talk to your page. It uses postMessage, which sends a copy of the data. The copy is made with the structured clone algorithm, the browser's way of deep-copying data. So the receiver gets plain data, never the sender's live object.

When you send, the second argument is the targetOrigin. It names the origin that is allowed to receive the message. Use a real origin, like "https://pay.example", for anything private. Use "*", which means "any origin", only when the receiver has an opaque origin you can't name. A sandboxed srcdoc frame is one example. Never send secrets with "*".

When you receive, check three things before you use the message:

  1. Origin: is event.origin on your list of allowed origins?
  2. Source: is event.source the exact frame window you saved when you made the frame?
  3. Shape: does event.data have the type and fields you expect?
A message goes through three checks in a row: origin, is it pay.example? Source, is it the same frame? Shape, is the checkout ID a string? Only after all three does the handler accept it.messageoriginpay.example?sourcesame frame?shapestring ID?accept
The handler checks the message three times, in this order. If any check fails, it rejects the message and stops. Only a message that passes all three is accepted.
Accept only the expected payment messagePop out in the code editor (opens in a new tab)JavaScript
const paymentFrame = {};
const ready = { origin: "https://pay.example", source: paymentFrame, data: { type: "payment:ready", checkoutId: "co_42" } };
const lookalike = { ...ready, source: {} };
const accepts = (event) => event.origin === "https://pay.example" && event.source === paymentFrame &&
  event.data && !Array.isArray(event.data) && event.data.type === "payment:ready" &&
  typeof event.data.checkoutId === "string";
const cases = [ready, lookalike, { ...ready, data: [] }];
console.log(cases.map((event) => accepts(event) ? "accept" : "reject").join(", "));

This code uses plain objects that look like message events, so you can run it anywhere. Line by line:

  • Line 1 stands in for the payment frame you made.
  • Line 2 is a good message from that frame.
  • Line 3 copies it, but changes the source to a different frame. Same origin, wrong frame.
  • Lines 4 to 6 check the exact origin and the exact frame. Then they check that the data is an object (not an array), its type is "payment:ready", and its checkout ID is a string.
  • Line 7 adds a third message whose data is an empty array.
  • Line 8 prints accept, reject, reject.

The second message has the right origin, but it is still rejected. A matching origin alone is not enough.

Two more details about origins:

  • When you send, targetOrigin also protects you if the frame has moved to another site. The browser won't deliver the message there.
  • When you receive, event.origin is the sender's origin at the moment it sent the message. It does not change if that window moves later.

In the step-through below, pick an incoming message. Then watch which check stops it.

Step through a safe message handler
Step 0 of 7Ready
Your turn: follow the blue line

Pick an incoming message. Then step through the three checks: origin, source, and shape.

Running in
  1. script
Next: line 1
const trustedSource = globalThis.paymentFrame ?? {};const incomingEvent = globalThis.incomingEvent ?? { origin: "https://pay.example", source: trustedSource, data: { type: "payment:ready", checkoutId: "co_42" } };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));
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
Pick the incoming message
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

For a longer private conversation, use a MessageChannel. It makes two connected ends, called ports. You keep one port and send the other to the frame or worker, inside your first postMessage. After that, the two ports talk only to each other, like a private phone line. Other frames can't send to it.

postMessage playground: public messages and a private port
Send a private port to the frameJavaScript
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" });
Sandboxed framesorigin null

Press a button to send a message.

Try it yourself

Both frames have the origin null, so the origin check can't tell them apart. The handler also checks the exact event.source. The last button sends one end of a MessageChannel, a private line, to the trusted frame.

This demo sends with targetOrigin: "*" only because these sandboxed srcdoc frames have opaque origins. It sends no secrets.

Cross-origin isolation

In 2018, researchers found a kind of attack called Spectre. It measures tiny differences in how long the processor takes to do things. From those timings, a page can sometimes guess data from memory it should never see, even data from another site.

Browsers responded by turning off SharedArrayBuffer (memory that several threads can share) and some very precise timing and memory tools. A page gets them back only if it asks to be kept strictly apart from other sites. This is called cross-origin isolation. Your code can check it with self.crossOriginIsolated, which is true or false.

The table below lists the pieces. Read each row as: this piece, what it looks like, and what it does.

The pieces that make a page cross-origin isolated
PieceWhat it looks likeWhat it does
COOPCross-Origin-Opener-Policy: same-originA header on your page. It cuts the link to windows from other sites.
COEPCross-Origin-Embedder-Policy: require-corp or credentiallessA header on your page. Every file it loads must agree to be loaded, or is loaded without cookies.
CORPCross-Origin-Resource-Policy: same-origin or cross-originA header on each loaded file. It says which sites may load that file.
The check in codeself.crossOriginIsolatedIt is true only when the page meets all the isolation rules.
What you get backSharedArrayBuffer and precise timing and memory toolsBrowsers turned these off after Spectre. They come back only on isolated pages.
Permissions Policycross-origin-isolatedThis setting can still block those tools, even when the headers are there.

Three of these pieces are headers, but they go on different responses:

  • Cross-Origin-Opener-Policy (COOP) is a header on your page. It cuts the link between your page and windows from other sites that opened it, or that it opens.
  • Cross-Origin-Embedder-Policy (COEP) is also a header on your page. It says that every image, script, or frame your page loads must agree to be loaded.
  • Cross-Origin-Resource-Policy (CORP) is a header on each of those loaded files. It is how a file agrees. A file can also agree through CORS.

One more rule can still block the powerful tools: the cross-origin-isolated setting of Permissions Policy. So always check self.crossOriginIsolated in code. Don't assume the headers worked.

The two headers your server sends (not JavaScript)HTTP
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Three checks a page can runJavaScript
console.log(self.crossOriginIsolated);console.log(typeof SharedArrayBuffer);console.log(typeof performance.measureUserAgentSpecificMemory);

Line 1 prints whether the page is cross-origin isolated. Line 2 prints the type of SharedArrayBuffer. Line 3 prints the type of a memory tool, performance.measureUserAgentSpecificMemory. On a normal page, lines 2 and 3 print undefined.

Use shared memory only when it is allowedPop out in the code editor (opens in a new tab)JavaScript
const useSharedMemory =
  self.crossOriginIsolated && typeof SharedArrayBuffer === "function";
const buffer = useSharedMemory ? new SharedArrayBuffer(16) : new ArrayBuffer(16);
console.log(buffer.constructor.name);

Lines 1 and 2 check two things: is the page isolated, and does SharedArrayBuffer exist? Line 3 makes shared memory only when both are true. Otherwise it makes an ordinary ArrayBuffer. Line 4 prints ArrayBuffer in the lesson editor, because its preview is not cross-origin isolated.

Left: a page with no isolation headers. crossOriginIsolated is false, so the code makes an ArrayBuffer. Right: a page sent with COOP and COEP headers. crossOriginIsolated is true, so the code makes a SharedArrayBuffer.no extra headerspagecrossOriginIsolatedfalseArrayBufferCOOP + COEP sentpagecrossOriginIsolatedtrueSharedArrayBuffer
Without the two headers, self.crossOriginIsolated is false and the code falls back to an ordinary ArrayBuffer. With COOP and COEP, it is true and shared memory is allowed.

We checked this in Chrome 154 while writing this lesson. On a normal data: page, self.crossOriginIsolated was false, and both SharedArrayBuffer and performance.measureUserAgentSpecificMemory were undefined. On a page sent with COOP: same-origin and COEP: require-corp, all three were available.

What these headers can break

COEP blocks images, scripts, frames, and analytics from other sites that don't send CORS or CORP headers. The credentialless value helps in some cases: it loads those files without cookies. But payment widgets, ads, and social media embeds may still break. Then you need a page without isolation, or a different way to add them.

COOP can also cut the link to a popup. Some sign-in flows use a popup that talks back to your page, so test them before you turn these headers on.

Workers, ShadowRealm, and Node vm

A Web Worker runs a script in the background, on its own thread. It has its own set of global variables, and it can't touch the DOM. That makes it a good place for work that only needs data, like these:

  • parsing a big file
  • processing an image
  • plugin code that turns data into other data

But a worker alone does not keep harmful code safe. It can still keep the processor busy, use lots of memory, make network requests that its origin allows, and send bad messages. Your page must still check what comes back.

A worker gets data, not the DOMJavaScript
// 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() });};

Line 2 starts the worker. Line 3 sends it some text. Line 4 sets up a function for the worker's reply, and line 5 shows the reply as plain text. Inside the worker, line 9 receives the message. Line 10 is a reminder that there is no document here. Line 11 sends back the text in capital letters.

ShadowRealm is a proposal, an idea for a new JavaScript feature that is not finished yet. TC39, the group that designs JavaScript, is working on it. A realm is a complete set of JavaScript's built-in objects, like Array and Object, plus its own global object. A ShadowRealm would give you a fresh one. The proposed tools are:

  • new ShadowRealm() to make a new realm
  • realm.evaluate(sourceText) to run a string of code inside it
  • realm.importValue(specifier, bindingName) to load one exported value from a module inside it

The proposal is at Stage 2.7, which means it is close to done but not final. In Chrome 154, typeof ShadowRealm is "undefined". So the example below checks first, and the lesson does not run it.

Check for ShadowRealm before you use itJavaScript
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");}

Line 1 checks that ShadowRealm exists. Line 2 makes a realm. Line 3 loads a function called twice from a module inside it, and line 4 calls it. Line 5 runs a string of code inside the realm with evaluate. Inside the realm, Array.prototype and globalThis.Array.prototype are the same object, so it would print true. In today's Chrome, only line 7 runs.

ShadowRealm does not make code safe

A fresh realm gives code its own built-in objects. It does not stop code that loops forever or uses up all the memory. It does not block timing attacks, and it does not make safe any tool you hand to the code. Think of it as a future language feature. It does not replace an iframe, a worker, a separate process, or careful permissions.

Node's vm module has a similar trap in its name. It runs code in a separate set of global variables. The official Node.js documentation says: "The node:vm module is not a security mechanism. Do not use it to run untrusted code." When code may be harmful, use a stronger boundary:

  • a separate operating-system process
  • a container
  • a separate service on another machine
  • a carefully built system that hands code only the tools it needs
Node vm runs code, but is not a security boundaryJavaScript
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.

Line 3 makes a context, a separate set of global variables, with answer set to 41. Line 4 runs code inside it that adds 1. Line 5 prints 42. Lines 7 and 8 repeat the warning from the Node docs.

SES (Secure ECMAScript), also called Hardened JavaScript, takes another path. It works in two steps:

  1. lockdown() freezes JavaScript's built-in objects, so code can't change them.
  2. A Compartment runs code with only the tools you pass in. Nothing else is reachable.

This can be a good base for a plugin system. But your whole app must be built around it from the start. You can't add it in one line to a page that already trusts every script.

SES: lock down, then run code with chosen toolsJavaScript
import "ses";lockdown(); const compartment = new Compartment({ console });compartment.evaluate("console.log(Array.prototype.map.call([1, 2], x => x * 2))");

Line 1 loads SES. Line 2 freezes the built-in objects. Line 4 makes a compartment and passes in only console. Line 5 runs code inside it, which prints the array [2, 4].

The table below compares all five tools. Read each row as: this tool, what it keeps apart, when to use it, and what it does not do.

Which tool keeps what apart
ToolWhat it keeps apartWhen to use itWhat it does not do
Sandboxed iframeA whole page, with only the powers its tokens allow.Widgets, ads, previews of customer HTML, and code playgrounds.It does not check messages or tokens for you. You still have to.
Web WorkerCode that runs off the main thread and can't touch the DOM.Heavy data work, image work, and plugin code that only needs data.It can still keep the processor busy and use the network and storage its origin allows.
ShadowRealmA fresh set of global variables and built-in objects, like Array.Plugin-style code in the future, once browsers support it.It does not stop endless loops, timing attacks, or misuse of tools you hand in.
Node vmA separate set of global variables inside one Node process.Trusted tools, tests, or templates.The Node docs say it is not for untrusted code.
SES lockdownFrozen built-in objects, plus compartments that get only the tools you pass in.Plugin systems built around passing in only chosen tools.Your whole app must be built around it from the start.

Choosing a boundary in real apps

Pick the tool by what the code needs:

  • It needs to show something on screen, like ads, comments written in HTML, previews, payment widgets, or code playgrounds: use a sandboxed iframe.
  • It only needs to crunch data, with no DOM: use a worker.
  • It needs SharedArrayBuffer or precise memory measurement: turn on cross-origin isolation, on that page only.
  • It may be actively harmful: run it in a separate operating-system process or container, with limits on processor time and memory. Node's vm alone is not enough.

Keep the defenses from the XSS lesson too. A sandbox is an extra layer of defense. It does not clean dangerous HTML for you.

Here is what that looks like for four common jobs:

  • A code playground: use sandbox="allow-scripts", and send results back with messages. Don't give the frame your origin.
  • A payment widget: load it from the payment company's origin. Send to that exact targetOrigin, and check every message against a strict list of allowed shapes.
  • Customer HTML: clean it first. Then preview it in a sandbox that doesn't share your origin.
  • Plugin data work: start with a worker and a small set of allowed commands. Move to a separate process if the risk is higher.

Common mix-ups

"A sandboxed iframe is always safe."

It depends on the tokens and on your message handler. allow-scripts plus allow-same-origin on content from your own origin can undo the sandbox. And a careless message handler can still let a bad message change your page.

"Sending with "*" is fine, because the receiver checks the origin."

The receiver's check can't stop a message from reaching the wrong window. Only the sender's targetOrigin can. Use a real origin for anything secret. Use "*" only for opaque origins and messages with no secrets.

"Workers keep harmful code safe."

Workers can't touch the DOM, which helps. But they still run code. That code can loop forever, use up memory, and send harmful messages.

"ShadowRealm will make any plugin code safe."

It gives code its own built-in objects and global variables. It does not limit processor time, block timing attacks, or decide which tools your app hands to the code.

Practice exercises

Exercise 1 · Warm-upPredict the frame's origin

Read the list of tokens. What origin would this sandboxed frame report?

Starter codePop out in the code editor (opens in a new tab)JavaScript
const tokens = ["allow-scripts"];
const origin = tokens.includes("allow-same-origin") ? "real origin" : "null";
console.log(origin);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · PracticeReject a message from the wrong frame

    Follow the checks. Does the code accept or reject the message?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    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");

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeChoose the token for a code playground

      A code editor must run JavaScript examples, but must not give them the site's origin. Which one sandbox token does it need?

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeName the two isolation headers

        Which two response headers does a page send to make crossOriginIsolated true?

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        const headers = ["Cross-Origin-Opener-Policy", "Cross-Origin-Embedder-Policy"];
        console.log(headers.join(" + "));

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeSpot the unsafe token pair in a code review

          You are reviewing a teammate's code. Which two tokens should never be used together to protect your page from untrusted content from your own origin?

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          const tokens = ["allow-scripts", "allow-same-origin"];
          console.log(tokens.includes("allow-scripts") && tokens.includes("allow-same-origin"));

          Answer, then press Check. Spacing and letter case don’t matter.

            Check your understanding

            Isolation and sandboxing quiz · 7 questionsScore: first tries count
            1. Question 1 of 7What does isolation mean in this lesson?

              Choose an answer to see the explanation.

            2. Question 2 of 7What does this sandbox-origin check print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const tokens = ["allow-scripts"];
              const origin = tokens.includes("allow-same-origin") ? "real origin" : "null";
              console.log(origin);

              Choose an answer to see the explanation.

            3. Question 3 of 7What does this message check print for a message from the wrong frame?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              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");

              Choose an answer to see the explanation.

            4. Question 4 of 7When is it OK to send with targetOrigin set to "*"?

              Choose an answer to see the explanation.

            5. Question 5 of 7Which two headers usually make self.crossOriginIsolated === true?

              Choose an answer to see the explanation.

            6. Question 6 of 7What is true about ShadowRealm?

              Choose an answer to see the explanation.

            7. Question 7 of 7Which sentence about Node's vm module matches the official docs?

              Choose an answer to see the explanation.

            Key takeaways

            • Start a sandboxed iframe with every power blocked. Then add back only the tokens it really needs.
            • Never use allow-scripts plus allow-same-origin to protect your page from content from your own origin.
            • Send with a real targetOrigin when you can. When you receive, check the origin, the source, and the shape.
            • Cross-origin isolation is turned on with headers, for pages that need powerful memory tools. It is not a sandbox for widgets.
            • Workers, ShadowRealm, Node vm, and SES each keep a different thing apart. Choose by what could go wrong.

            Isolation lets untrusted code do useful work through a small, checked channel, instead of giving it your whole page.

            Next in this module, Web Crypto shows the browser's built-in tools for secure randomness, hashing, and encryption, so you never write your own. The habit is the same: pick the browser tool that gives you the protection you need.

            CompleteFrontend Clear concepts. Working examples.