Sandboxed Code Execution in the Browser
An in-depth look at how to safely execute user-written code in the browser using iframes, Content Security Policy, and message-passing architectures to maintain security without sacrificing functionality.
Running arbitrary user code in the browser is inherently dangerous. Without proper isolation, a malicious or buggy script can access cookies, read local storage, make network requests on behalf of the user, or manipulate the host page's DOM. Code playgrounds like Kodepad must solve this problem convincingly: users expect to write and run any valid HTML, CSS, and TypeScript, while the host application must remain completely isolated from the executed code. This article explains the sandboxing architecture Kodepad uses to achieve this balance, covering iframe isolation, Content Security Policy, the message-passing protocol, and the practical edge cases that make sandboxing harder than it first appears.
The Sandboxed Iframe Foundation
The browser's same-origin policy is the first line of defense. By serving the execution iframe from a different origin than the host application, we ensure that code running inside the iframe cannot access the host's DOM, cookies, or storage. In Kodepad, the preview iframe loads from a dedicated subdomain:
// src/sandbox/create-sandbox.ts
interface SandboxConfig {
sandboxOrigin: string; // e.g., "https://sandbox.kodepad.dev"
container: HTMLElement;
onReady: () => void;
onConsole: (level: string, args: unknown[]) => void;
onError: (error: { message: string; line: number; column: number }) => void;
}
function createSandbox(config: SandboxConfig): HTMLIFrameElement {
const iframe = document.createElement("iframe");
// The sandbox attribute restricts the iframe's capabilities
iframe.sandbox.add("allow-scripts");
// Notably absent: allow-same-origin, allow-forms, allow-popups,
// allow-top-navigation
iframe.src = `${config.sandboxOrigin}/runner.html`;
iframe.style.cssText = `
width: 100%;
height: 100%;
border: none;
background: white;
`;
// Listen for messages from the sandbox
const handleMessage = (event: MessageEvent) => {
if (event.origin !== config.sandboxOrigin) return;
switch (event.data.type) {
case "ready":
config.onReady();
break;
case "console":
config.onConsole(event.data.level, event.data.args);
break;
case "error":
config.onError(event.data.error);
break;
}
};
window.addEventListener("message", handleMessage);
config.container.appendChild(iframe);
return iframe;
}The sandbox attribute on the iframe is critical. By adding only allow-scripts, we permit JavaScript execution but deny everything else: form submission, popup creation, top-level navigation, and same-origin access. This means even if the user writes document.cookie, they get the sandboxed iframe's empty cookie jar, not the host application's session tokens.
Omitting allow-same-origin is the most important decision. Without it, the iframe is treated as a unique, opaque origin that cannot access any storage or cookies associated with the sandbox domain. This is essential because it prevents a crafted script from reading authentication tokens even if the sandbox subdomain shares cookies with the main domain.
Content Security Policy as Defense in Depth
The iframe sandbox attribute provides strong isolation, but we add Content Security Policy headers on the sandbox origin as a second layer. CSP restricts what resources the sandboxed code can load, even if a vulnerability in the sandbox mechanism is discovered:
// The sandbox origin's HTTP response headers
const SANDBOX_CSP_HEADERS = {
"Content-Security-Policy": [
"default-src 'none'",
"script-src 'unsafe-inline' 'unsafe-eval'",
"style-src 'unsafe-inline'",
"img-src data: blob: https:",
"font-src data: https://fonts.gstatic.com",
"connect-src 'none'",
"frame-src 'none'",
"object-src 'none'",
"base-uri 'none'",
].join("; "),
"X-Frame-Options": "SAMEORIGIN",
"X-Content-Type-Options": "nosniff",
};The most consequential directive is connect-src 'none', which blocks all network requests from the sandboxed code, including fetch, XMLHttpRequest, WebSocket, and EventSource. This prevents data exfiltration even if an attacker manages to execute code in the sandbox context. For playgrounds that need network access, you can selectively allow specific API endpoints, but the default-deny posture is safer.
We allow 'unsafe-inline' and 'unsafe-eval' for scripts because user-written code fundamentally requires both. The user's TypeScript is compiled to JavaScript and injected inline, and some patterns rely on eval or Function constructors. The isolation provided by the iframe sandbox makes this acceptable.
The Message-Passing Protocol
Communication between the host application and the sandbox occurs exclusively through postMessage. This is the only channel that crosses the origin boundary, and we treat it as an untrusted API on both sides. The host never trusts data from the sandbox without validation, and the sandbox validates that messages come from the expected parent origin:
// src/sandbox/protocol.ts
type HostToSandboxMessage =
| { type: "execute"; html: string; css: string; js: string; id: string }
| { type: "reset" }
| { type: "dispose" };
type SandboxToHostMessage =
| { type: "ready" }
| { type: "console"; level: string; args: SerializedValue[]; id: string }
| { type: "error"; error: { message: string; line: number; column: number }; id: string }
| { type: "execution-complete"; id: string; duration: number };
type SerializedValue =
| { kind: "string"; value: string }
| { kind: "number"; value: number }
| { kind: "boolean"; value: boolean }
| { kind: "null" }
| { kind: "undefined" }
| { kind: "object"; preview: string }
| { kind: "error"; message: string; stack?: string };
// Host-side: sending code to the sandbox
function executeInSandbox(
iframe: HTMLIFrameElement,
sandboxOrigin: string,
code: { html: string; css: string; js: string }
): string {
const id = crypto.randomUUID();
const message: HostToSandboxMessage = {
type: "execute",
...code,
id,
};
iframe.contentWindow?.postMessage(message, sandboxOrigin);
return id;
}Every execution gets a unique ID, which allows the host to correlate console output and errors with the specific execution that produced them. This is important when rapid re-execution occurs, such as when the user types quickly with auto-run enabled. Without execution IDs, console messages from a stale execution could appear to belong to the current one.
The Sandbox Runner
Inside the sandbox iframe, a runner script receives execution messages and orchestrates the code injection. The runner replaces the iframe's document content entirely on each execution to ensure a clean state:
// runner.html inline script (runs inside the sandbox iframe)
const PARENT_ORIGIN = "https://kodepad.dev";
// Override console methods to relay output to the host
const originalConsole = { ...console };
function createConsoleProxy(executionId: string) {
const levels = ["log", "warn", "error", "info", "debug"] as const;
levels.forEach((level) => {
console[level] = (...args: unknown[]) => {
originalConsole[level](...args);
parent.postMessage(
{
type: "console",
level,
args: args.map(serializeValue),
id: executionId,
},
PARENT_ORIGIN
);
};
});
}
function serializeValue(value: unknown): SerializedValue {
if (value === null) return { kind: "null" };
if (value === undefined) return { kind: "undefined" };
if (typeof value === "string") return { kind: "string", value };
if (typeof value === "number") return { kind: "number", value };
if (typeof value === "boolean") return { kind: "boolean", value };
if (value instanceof Error) {
return { kind: "error", message: value.message, stack: value.stack };
}
try {
const preview = JSON.stringify(value, null, 2);
return { kind: "object", preview: preview.substring(0, 5000) };
} catch {
return { kind: "object", preview: String(value) };
}
}
window.addEventListener("message", (event) => {
if (event.origin !== PARENT_ORIGIN) return;
if (event.data.type !== "execute") return;
const { html, css, js, id } = event.data;
const start = performance.now();
createConsoleProxy(id);
// Set up error handler for this execution
window.onerror = (message, _source, line, column) => {
parent.postMessage(
{ type: "error", error: { message: String(message), line, column }, id },
PARENT_ORIGIN
);
};
// Write the complete document
document.open();
document.write(`
<!DOCTYPE html>
<html>
<head><style>${css}</style></head>
<body>
${html}
<script>${js}<\/script>
</body>
</html>
`);
document.close();
parent.postMessage(
{ type: "execution-complete", id, duration: performance.now() - start },
PARENT_ORIGIN
);
});
// Signal readiness
parent.postMessage({ type: "ready" }, PARENT_ORIGIN);The document.open() / document.write() / document.close() pattern replaces the entire document, which is more thorough than clearing innerHTML. It resets event listeners, timers, and any global state from the previous execution. This ensures that each run starts from a clean slate.
Handling Infinite Loops and Resource Exhaustion
User code can accidentally (or intentionally) enter an infinite loop, freezing the browser tab. Since the sandbox runs on the main thread of its iframe, a tight loop blocks everything, including the message handler that would receive a "stop" command.
The most reliable mitigation is to inject loop counters during compilation:
// src/sandbox/loop-guard.ts
function injectLoopGuards(code: string): string {
const MAX_ITERATIONS = 100_000;
let guardCounter = 0;
// Inject a counter check at the start of every loop body
return code.replace(
/(for\s*\([^)]*\)\s*\{|while\s*\([^)]*\)\s*\{|do\s*\{)/g,
(match) => {
const varName = `__loopGuard${guardCounter++}`;
return `${match} var ${varName} = 0; if (++${varName} > ${MAX_ITERATIONS}) { throw new Error("Infinite loop detected: exceeded ${MAX_ITERATIONS} iterations"); }`;
}
);
}This approach has limitations. It does not catch infinite recursion, and regex-based code transformation is fragile. A more robust solution uses the TypeScript compiler's AST transformation API to insert guards at every loop and recursive call site. In Kodepad, we combine loop guards with a Web Worker-based timeout: if the sandbox does not send an execution-complete message within five seconds, we destroy and recreate the iframe.
Conclusion
Sandboxed code execution in the browser requires multiple overlapping security mechanisms. No single technique is sufficient. The iframe sandbox attribute provides origin isolation and capability restriction. Content Security Policy adds defense in depth against resource loading and network access. The message-passing protocol ensures that the host and sandbox communicate through a narrow, validated channel. And loop guards protect against resource exhaustion.
The architecture described here reflects the layers of defense Kodepad uses in production. Each layer is designed to function independently, so a failure in one does not compromise the others. When building your own sandbox, start with the iframe sandbox attribute and add layers based on your threat model. Trust no input from the sandboxed context, validate every message, and design for the assumption that user code will do everything the security model allows, because it will.
Related Articles
Rapid Prototyping: From Idea to Demo in Minutes
How browser-based code playgrounds enable rapid prototyping workflows that compress the journey from concept to working demo, and why this capability is a competitive advantage for engineering teams.
Code Playgrounds in Developer Education
How code playgrounds are transforming developer education by providing immediate feedback, reducing setup barriers, and enabling interactive learning experiences that scale.
Developer Tools That Boost Productivity
An exploration of how browser-based developer tools like code playgrounds reduce friction in the development workflow, accelerating everything from prototyping to debugging to knowledge sharing.