Running a TypeScript playground is where nice, clean CSP ideas go to get punched in the face.
You want a browser app that accepts arbitrary code, transpiles it, maybe runs it in a preview, stores snippets, loads analytics, and still keeps a tight Content Security Policy. That combination gets messy fast. I’ve built this kind of thing before, and the first version usually cheats: unsafe-inline, unsafe-eval, and a quiet promise to “fix it later.”
That “later” usually never comes.
Here’s a real-world case study for a TypeScript playground on csp-examples, with the ugly starting point, the breakages you’ll hit, and the version I’d actually ship.
The setup
Our fictional playground does a few normal things:
- Serves a TypeScript editor UI
- Compiles TS to JS in the browser
- Renders output in a preview pane
- Saves snippets through an API
- Tracks usage with analytics
- Uses a consent manager
That mix is already enough to blow up a naive CSP.
For reference, here’s a real production-style CSP from headertest.com:
content-security-policy:
default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
script-src 'self' 'nonce-NzAzMDQzZmMtNGZkNC00MDFlLTg4NTctMmQyZTlmYmY1NDE2' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com;
img-src 'self' data: https:;
font-src 'self';
connect-src 'self' https://api.headertest.com https://tallycdn.com https://or.headertest.com wss://or.headertest.com https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com;
frame-src 'self' https://consentcdn.cookiebot.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none'
That header is a good reminder that real CSPs are rarely tiny. Once you add analytics, consent tooling, APIs, and WebSockets, the policy grows up fast.
The bad first version
Here’s the kind of CSP teams start with when they’re trying to get a playground working before lunch:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
connect-src 'self';
frame-src 'self';
And here’s why it “works”:
unsafe-inlineallows inline boot scripts and style tagsunsafe-evalkeeps browser-side transpilers and some bundler output aliveframe-src 'self'allows an internal preview frameconnect-src 'self'covers same-origin APIs
It also leaves the front door open.
If your playground accepts user code and you have any XSS bug in the host page, unsafe-inline and unsafe-eval turn that into a gift basket for attackers.
The before code
A lot of playgrounds start with HTML like this:
<div id="app"></div>
<script>
window.__PLAYGROUND_CONFIG__ = {
apiBase: "/api",
version: "1.0.0"
};
</script>
<script src="/static/app.js"></script>
And TypeScript like this:
const runButton = document.getElementById("run")!;
runButton.addEventListener("click", async () => {
const source = (document.getElementById("editor") as HTMLTextAreaElement).value;
const result = eval(transpile(source)); // bad idea, but common in prototypes
const preview = document.getElementById("preview")!;
preview.innerHTML = String(result);
});
That code forces your hand:
- inline config script needs
unsafe-inlineor a nonce eval()needsunsafe-evalinnerHTMLcreates an XSS sink right where users expect untrusted code
This is exactly the point where developers blame CSP for being annoying, when the app architecture is really the problem.
What actually broke when we tightened CSP
The first serious policy I’d try is this:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-rAnd0m123';
style-src 'self';
img-src 'self' data:;
connect-src 'self';
frame-src 'self';
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
form-action 'self';
Immediately, three things break:
- The inline config script stops working
- Any runtime that uses
eval,new Function, or similar dies - The preview rendering fails if it depends on injecting arbitrary HTML into the parent page
Good. That’s the browser telling you where the unsafe design is.
The after architecture
The fix wasn’t “add back unsafe stuff.” The fix was changing the playground design.
1. Replace inline scripts with nonce-based bootstrapping
Server-rendered page:
<div id="app"></div>
<script nonce="{{.CSPNonce}}">
window.__PLAYGROUND_CONFIG__ = {
apiBase: "/api",
version: "1.0.0"
};
</script>
<script nonce="{{.CSPNonce}}" src="/static/app.js"></script>
Header:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{{RANDOM_NONCE}}' 'strict-dynamic';
style-src 'self';
img-src 'self' data:;
connect-src 'self' https://api.csp-examples.test;
frame-src 'self';
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
form-action 'self';
If you need a refresher on directives like strict-dynamic, https://csp-guide.com is a solid reference.
I like this approach because it scales. You stop whitelisting random script origins unless you truly need them.
2. Stop executing user code in the host page
This is the big one.
Don’t run untrusted user code in the same DOM that holds your app shell, auth state, and API wiring. Put it in a sandboxed iframe.
<iframe
id="preview-frame"
sandbox="allow-scripts"
src="/playground/preview"
></iframe>
And then send compiled code into that frame using postMessage:
const frame = document.getElementById("preview-frame") as HTMLIFrameElement;
function runCompiledCode(js: string) {
frame.contentWindow?.postMessage(
{ type: "run-preview", code: js },
window.location.origin
);
}
Inside /playground/preview, keep the page minimal:
<!doctype html>
<html>
<body>
<div id="root"></div>
<script nonce="{{.CSPNonce}}" src="/static/preview.js"></script>
</body>
</html>
Then in preview.js:
window.addEventListener("message", (event) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type !== "run-preview") return;
const root = document.getElementById("root")!;
root.textContent = "";
// Render as text or feed into a controlled runtime.
// Avoid dumping raw HTML into the DOM.
root.textContent = event.data.code;
});
For a true code runner, I’d still isolate aggressively. The host page should not trust the preview frame, and the preview frame should have an even tighter CSP than the main app.
3. Remove unsafe-eval by changing the toolchain
This is where many playgrounds get stuck.
Some browser-based transpilation paths rely on eval or function constructors. If that’s your stack, CSP is exposing a product decision, not just a header problem.
Better options:
- transpile in a Web Worker without dynamic code execution
- compile on the server
- use a TypeScript pipeline that emits static JS without runtime eval tricks
For example, move compilation into a worker:
const worker = new Worker("/static/compiler-worker.js", { type: "module" });
worker.postMessage({ source });
worker.onmessage = (event) => {
const { js, error } = event.data;
if (error) {
showError(error);
return;
}
runCompiledCode(js);
};
That doesn’t magically solve everything, but it usually helps separate “compile code” from “execute code,” which is exactly what you want when locking down CSP.
Adding real-world third parties
Most apps aren’t pure. You’ll eventually add analytics or consent tooling, and then your clean CSP gets more realistic.
Using the headertest.com example as a model, a production playground might end up with something like:
Content-Security-Policy:
default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
script-src 'self' 'nonce-{{RANDOM_NONCE}}' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com;
img-src 'self' data: https:;
font-src 'self';
connect-src 'self' https://api.csp-examples.test wss://ws.csp-examples.test https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com;
frame-src 'self' https://consentcdn.cookiebot.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none';
A few opinions here:
object-src 'none'should be basically automaticframe-ancestors 'none'is a great default for apps that should never be embeddedbase-uri 'self'is cheap and worth itstyle-src 'unsafe-inline'is often the stubborn compromise because of CSS-in-JS or consent tools, but I try hard not to accept that for scripts
If you can only be strict in one place, be strict in script-src.
Before and after, side by side
Before
Content-Security-Policy:
default-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
connect-src 'self';
const result = eval(transpile(source));
preview.innerHTML = String(result);
After
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{{RANDOM_NONCE}}' 'strict-dynamic';
style-src 'self';
img-src 'self' data:;
connect-src 'self' https://api.csp-examples.test wss://ws.csp-examples.test;
frame-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
worker.postMessage({ source });
worker.onmessage = (event) => {
if (event.data.js) {
frame.contentWindow?.postMessage(
{ type: "run-preview", code: event.data.js },
window.location.origin
);
}
};
That’s the real shift: not “better syntax for CSP,” but a better trust boundary.
What I’d ship for csp-examples
For a TypeScript playground demo site, I’d ship two separate policies:
- Main app: tight, nonce-based, no
unsafe-eval - Preview frame: isolated, sandboxed, minimal privileges, separate CSP
That split makes the playground usable without pretending untrusted code is safe in the main document.
CSP works best when it reinforces a good design. It works badly when it’s asked to bless a dangerous one. A TypeScript playground is the perfect example. If you keep user code away from your app shell, drop inline scripts behind nonces, and stop depending on eval, the policy gets dramatically simpler — and a lot more honest.
For the actual directive details, the official docs are still the source of truth: https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP. For quick directive explanations and examples, https://csp-guide.com is handy.