If you embed tldraw in a page, your CSP usually breaks in one of three places:

  1. the iframe itself gets blocked
  2. tldraw can load, but its network calls fail
  3. collaborative features fail because WebSocket or worker policies are too tight

This guide is the practical version: what to allow, what to avoid, and copy-paste policies you can start with.

I’m assuming you already know basic CSP syntax. If you need a refresher on directives, https://csp-guide.com is a solid reference.

The two tldraw embed models

You’ll usually hit one of these setups:

1. Hosted embed in an iframe

Your app loads a remote tldraw page with something like:

<iframe
  src="https://viewer.tldraw.com/your-doc"
  width="100%"
  height="600"
  loading="lazy"
  referrerpolicy="strict-origin-when-cross-origin"
  sandbox="allow-scripts allow-same-origin allow-downloads"
></iframe>

For this model, CSP mostly cares about:

  • frame-src for the iframe origin
  • maybe connect-src if your own page talks to APIs around the embed
  • frame-ancestors if you are serving the embedded page yourself

2. tldraw loaded directly in your app

You install tldraw in your frontend and render it in your own DOM:

import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'

export default function Whiteboard() {
  return <Tldraw />
}

Now CSP is stricter and more annoying. You may need:

  • script-src
  • style-src
  • img-src
  • font-src
  • connect-src
  • worker-src
  • sometimes blob: in specific directives

That difference matters. Don’t open up script-src for a simple iframe if all you really needed was frame-src.


Start with a sane baseline

Here’s a good default CSP baseline for a modern app that embeds a remote tldraw iframe:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  font-src 'self';
  connect-src 'self';
  frame-src 'self' https://viewer.tldraw.com;
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  object-src 'none';

Why this shape?

  • object-src 'none' should be muscle memory at this point.
  • base-uri 'self' blocks weird base tag abuse.
  • frame-ancestors 'none' stops your page from being embedded elsewhere. If your page must be framed by your own apps, change it.
  • script-src uses a nonce and strict-dynamic, which is better than piling hostnames into the policy.

The pattern is close to a real production CSP I’ve seen from headertest.com:

content-security-policy:
default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
script-src 'self' 'nonce-N2M2ZTk5N2YtZjVmZS00OGQyLThiYWMtNDhmMTllNjgyMGRh' '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’s a decent example of a tight-enough real-world policy: explicit sources, no object, and WebSocket origins called out in connect-src.


Copy-paste CSP for a basic tldraw iframe embed

If all you need is to display a hosted tldraw board in an iframe:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  font-src 'self';
  connect-src 'self';
  frame-src 'self' https://viewer.tldraw.com https://*.tldraw.com;
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  object-src 'none';

Nginx

add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'nonce-$request_id' 'strict-dynamic'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-src 'self' https://viewer.tldraw.com https://*.tldraw.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none';" always;

Express

app.use((req, res, next) => {
  const nonce = crypto.randomUUID()
  res.locals.nonce = nonce

  res.setHeader(
    'Content-Security-Policy',
    [
      "default-src 'self'",
      `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
      "style-src 'self' 'unsafe-inline'",
      "img-src 'self' data: https:",
      "font-src 'self'",
      "connect-src 'self'",
      "frame-src 'self' https://viewer.tldraw.com https://*.tldraw.com",
      "frame-ancestors 'none'",
      "base-uri 'self'",
      "form-action 'self'",
      "object-src 'none'",
    ].join('; ')
  )

  next()
})

If you know the exact tldraw host, use the exact host. Wildcards are lazy and usually unnecessary.


CSP for tldraw with collaboration or remote assets

If the embedded app uses collaboration, presence, synced documents, or remote assets, you’ll probably need more in connect-src.

Typical additions:

  • https://... API origin
  • wss://... WebSocket origin
  • maybe storage or asset CDN origins

Example:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: blob: https:;
  font-src 'self' data:;
  connect-src 'self' https://api.tldraw.com wss://api.tldraw.com https://*.tldraw.com wss://*.tldraw.com;
  frame-src 'self' https://viewer.tldraw.com https://*.tldraw.com;
  worker-src 'self' blob:;
  base-uri 'self';
  form-action 'self';
  object-src 'none';
  frame-ancestors 'none';

A few opinions here:

  • connect-src is where collaborative apps usually fail.
  • If you forget wss:, real-time features break in ways that look like random app bugs.
  • worker-src 'self' blob: is common for modern frontend tooling and canvas-heavy apps. Don’t add it unless you need it, but don’t be shocked if you do.

CSP for self-hosted tldraw in your own app

If you render tldraw directly in your app instead of inside an iframe, this is the more realistic starting point:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: blob: https:;
  font-src 'self' data:;
  connect-src 'self' https://api.tldraw.com wss://api.tldraw.com;
  worker-src 'self' blob:;
  base-uri 'self';
  form-action 'self';
  object-src 'none';
  frame-ancestors 'none';

Why no frame-src here? Because you’re not embedding a remote page. Add it only if your app also loads iframes.

Why blob: in img-src and worker-src? Because drawing apps often generate local previews, exports, or worker-backed processing. I try hard to avoid blob: in script-src, but img-src and worker-src are often fair game.


Iframe hardening that works well with CSP

CSP is only part of the story. Your iframe markup should also be locked down.

A decent default:

<iframe
  src="https://viewer.tldraw.com/your-doc"
  width="100%"
  height="600"
  sandbox="allow-scripts allow-same-origin"
  loading="lazy"
  referrerpolicy="strict-origin-when-cross-origin"
  allowfullscreen="false"
></iframe>

Sandbox notes

  • allow-scripts is usually required.
  • allow-same-origin is often needed by serious web apps, though it weakens sandbox isolation.
  • Avoid adding allow-top-navigation unless you absolutely need it.
  • Avoid allow-popups unless the embed genuinely needs it.

If the embed breaks, don’t immediately remove sandbox. Figure out which capability it actually needs.


Common breakages and the directive to fix

“Refused to frame because it violates frame-src”

Your page CSP is blocking the iframe.

Fix:

frame-src 'self' https://viewer.tldraw.com;

“Refused to connect”

Usually collaboration, analytics, asset sync, or document loading.

Fix connect-src:

connect-src 'self' https://api.tldraw.com wss://api.tldraw.com;

“Refused to create a worker” or worker load failure

Fix worker-src:

worker-src 'self' blob:;

Images or thumbnails don’t render

Fix img-src:

img-src 'self' data: blob: https:;

Your own embedded page can’t be framed by your app

You set:

frame-ancestors 'none';

That blocks all framing. If you serve the tldraw page yourself and want only your app to embed it:

frame-ancestors 'self' https://app.example.com;

This directive controls who can embed your page, not what your page can embed. People mix that up constantly.


Good and bad patterns

Good

frame-src 'self' https://viewer.tldraw.com;
connect-src 'self' https://api.tldraw.com wss://api.tldraw.com;
object-src 'none';
base-uri 'self';

Bad

default-src * data: blob: 'unsafe-inline' 'unsafe-eval';

That’s not a CSP. That’s giving up.

Also bad

script-src 'self' https://*.tldraw.com 'unsafe-inline';

If your app scripts are under your control, use nonces. Don’t fall back to unsafe-inline because one widget was annoying.


Report-only first if you’re not sure

If you’re adding tldraw to an existing app, start with report-only mode so you can see what breaks before production users do.

Content-Security-Policy-Report-Only:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: blob: https:;
  font-src 'self' data:;
  connect-src 'self' https://api.tldraw.com wss://api.tldraw.com;
  frame-src 'self' https://viewer.tldraw.com https://*.tldraw.com;
  worker-src 'self' blob:;
  base-uri 'self';
  form-action 'self';
  object-src 'none';
  frame-ancestors 'none';

Then tighten it based on actual violations instead of guessing.


Remote iframe embed

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  font-src 'self';
  connect-src 'self';
  frame-src 'self' https://viewer.tldraw.com;
  base-uri 'self';
  form-action 'self';
  object-src 'none';
  frame-ancestors 'none';

Self-hosted tldraw app with collaboration

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: blob: https:;
  font-src 'self' data:;
  connect-src 'self' https://api.tldraw.com wss://api.tldraw.com;
  worker-src 'self' blob:;
  base-uri 'self';
  form-action 'self';
  object-src 'none';
  frame-ancestors 'none';

That’s the version I’d start with, then trim hostnames and schemes once I know the exact runtime behavior. Tight CSPs are great, but only if they still let the app work.