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-inline allows inline boot scripts and style tags
  • unsafe-eval keeps browser-side transpilers and some bundler output alive
  • frame-src 'self' allows an internal preview frame
  • connect-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-inline or a nonce
  • eval() needs unsafe-eval
  • innerHTML creates 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:

  1. The inline config script stops working
  2. Any runtime that uses eval, new Function, or similar dies
  3. 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 automatic
  • frame-ancestors 'none' is a great default for apps that should never be embedded
  • base-uri 'self' is cheap and worth it
  • style-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:

  1. Main app: tight, nonce-based, no unsafe-eval
  2. 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.