If you embed tldraw in a page, your CSP usually breaks in one of three places:
- the iframe itself gets blocked
- tldraw can load, but its network calls fail
- 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-srcfor the iframe origin- maybe
connect-srcif your own page talks to APIs around the embed frame-ancestorsif 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-srcstyle-srcimg-srcfont-srcconnect-srcworker-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-srcuses a nonce andstrict-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 originwss://...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-srcis 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-scriptsis usually required.allow-same-originis often needed by serious web apps, though it weakens sandbox isolation.- Avoid adding
allow-top-navigationunless you absolutely need it. - Avoid
allow-popupsunless 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.
My recommended minimal policies
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.