Monaco Editor is one of those libraries that looks simple until you deploy it behind a strict Content Security Policy. Then the fun starts: web workers, dynamic loading, styles, fonts, and sometimes blob URLs depending on how you bundle it.
If you’re embedding Monaco on a site with a real CSP, you need to decide two things early:
- Are you self-hosting Monaco assets or pulling them from a CDN?
- Are Monaco workers loaded as separate files, or through
blob:URLs?
That choice changes your policy a lot.
I’m going to show a CSP that actually works, explain why Monaco tends to break under strict policies, and give code examples for common setups.
Why Monaco is tricky under CSP
Monaco Editor uses web workers for language services and editor features. Those workers are usually loaded from separate JavaScript files like:
editor.worker.jsjson.worker.jscss.worker.jshtml.worker.jsts.worker.js
From a CSP point of view, workers are the main problem.
Depending on your setup, Monaco may need:
script-srcfor its main scriptsworker-srcfor worker files orblob:style-srcfor editor CSSfont-srcif your build or theme pulls fontsconnect-srcif your app talks to APIs, telemetry, or language servers
A lot of people start with a broad policy, see the editor work, and stop there. That’s usually where unsafe-inline and blob: spread farther than necessary.
Start from a sane baseline
Here’s a strict baseline CSP for a self-hosted Monaco embed:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
style-src 'self';
img-src 'self' data:;
font-src 'self';
connect-src 'self';
worker-src 'self';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
object-src 'none';
This is intentionally tight. It assumes:
- your app scripts are served from your own origin
- Monaco files are self-hosted
- workers are loaded from real files on your origin
- no inline styles
- no blob workers
That’s the cleanest setup.
If you want a good reference for what these directives do, the directive docs on https://csp-guide.com are useful.
A real-world CSP header, and what it tells us
Here’s the real CSP header from headertest.com:
content-security-policy:
default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
script-src 'self' 'nonce-OGMzM2YwYWMtNmE3ZC00N2FlLWE4YzgtNTU4NzVlYjU1MDY5' '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'
This is a normal production policy: analytics, consent tooling, API calls, WebSocket connections. If you were adding Monaco to a page like this, I would not touch the existing allowlists until Monaco proves it needs something. Most of the time, the only Monaco-related additions are:
worker-src 'self'- maybe
script-src 'self'is already enough - maybe
style-src 'self'is already enough - maybe
worker-src blob:if your bundler uses blob workers
That “maybe” matters. Don’t cargo-cult blob: into your policy if you don’t need it.
Self-hosted Monaco with dedicated worker files
This is the setup I prefer.
Example app code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Monaco CSP Demo</title>
<link rel="stylesheet" href="/assets/monaco/vs/editor/editor.main.css">
</head>
<body>
<div id="editor" style="height: 400px;"></div>
<script type="module" nonce="{{ .CSPNonce }}">
import * as monaco from '/assets/monaco/vs/editor/editor.main.js';
self.MonacoEnvironment = {
getWorker(_, label) {
switch (label) {
case 'json':
return new Worker('/assets/monaco-workers/json.worker.js', { type: 'module' });
case 'css':
case 'scss':
case 'less':
return new Worker('/assets/monaco-workers/css.worker.js', { type: 'module' });
case 'html':
case 'handlebars':
case 'razor':
return new Worker('/assets/monaco-workers/html.worker.js', { type: 'module' });
case 'typescript':
case 'javascript':
return new Worker('/assets/monaco-workers/ts.worker.js', { type: 'module' });
default:
return new Worker('/assets/monaco-workers/editor.worker.js', { type: 'module' });
}
}
};
monaco.editor.create(document.getElementById('editor'), {
value: 'console.log("CSP-safe Monaco");',
language: 'javascript',
automaticLayout: true
});
</script>
</body>
</html>
Matching CSP
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
style-src 'self';
img-src 'self' data:;
font-src 'self';
connect-src 'self';
worker-src 'self';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
object-src 'none';
Why this works:
- Monaco main files are loaded from your own origin
- workers are loaded from your own origin
- no inline event handlers
- no
unsafe-eval - no
blob:
That’s the version I’d ship if I had control over asset hosting.
When Monaco needs blob: workers
Some bundlers package Monaco workers by generating blob URLs at runtime. In that case, worker-src 'self' is not enough. You need worker-src 'self' blob:.
Example using blob-backed workers
import * as monaco from 'monaco-editor';
self.MonacoEnvironment = {
getWorkerUrl(moduleId, label) {
const code = `
self.MonacoEnvironment = { baseUrl: '/' };
importScripts('/assets/monaco-workers/${label}.worker.js');
`;
const blob = new Blob([code], { type: 'text/javascript' });
return URL.createObjectURL(blob);
}
};
monaco.editor.create(document.getElementById('editor'), {
value: 'SELECT * FROM users;',
language: 'sql'
});
CSP for blob workers
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
style-src 'self';
img-src 'self' data:;
font-src 'self';
connect-src 'self';
worker-src 'self' blob:;
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
object-src 'none';
Two warnings here:
blob:inworker-srcis narrower than droppingblob:intoscript-src, which is good.- If your setup starts requiring
unsafe-eval, stop and inspect the build. Monaco itself does not mean you must allowunsafe-evalin modern setups.
Extending an existing production CSP
Let’s take the headertest.com policy and adapt it for a self-hosted Monaco embed with dedicated worker files.
Original shape
default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
script-src 'self' 'nonce-OGMzM2YwYWMtNmE3ZC00N2FlLWE4YzgtNTU4NzVlYjU1MDY5' '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';
Minimal Monaco addition
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.headertest.com https://tallycdn.com https://or.headertest.com wss://or.headertest.com https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com;
worker-src 'self';
frame-src 'self' https://consentcdn.cookiebot.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none';
That’s it if workers are served as files from your origin.
If your Monaco build uses blob workers:
worker-src 'self' blob:;
I would not add anything else unless the browser console proves it’s needed.
Express example with a nonce
If you’re serving HTML yourself, wire CSP and the script nonce together.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
app.use('/assets', express.static('public/assets'));
app.get('/editor', (req, res) => {
const nonce = crypto.randomUUID();
const csp = [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
"style-src 'self'",
"img-src 'self' data:",
"font-src 'self'",
"connect-src 'self'",
"worker-src 'self'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
"object-src 'none'"
].join('; ');
res.setHeader('Content-Security-Policy', csp);
res.send(`<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Monaco Editor</title>
<link rel="stylesheet" href="/assets/monaco/vs/editor/editor.main.css">
</head>
<body>
<div id="editor" style="height: 400px;"></div>
<script type="module" nonce="${nonce}">
import * as monaco from '/assets/monaco/vs/editor/editor.main.js';
self.MonacoEnvironment = {
getWorker(_, label) {
return new Worker('/assets/monaco-workers/editor.worker.js', { type: 'module' });
}
};
monaco.editor.create(document.getElementById('editor'), {
value: 'const answer = 42;',
language: 'javascript'
});
</script>
</body>
</html>`);
});
app.listen(3000);
Common CSP errors with Monaco
“Refused to create a worker”
You’re missing worker-src, or it doesn’t allow the actual source.
Fixes:
- add
worker-src 'self' - or
worker-src 'self' blob:if blob URLs are used
“Refused to load the script”
Your Monaco JS files aren’t covered by script-src.
Fix:
- self-host under
'self' - or explicitly allow the official asset origin if you insist on a CDN
“Refused to apply inline style”
This can happen in apps around Monaco more often than in Monaco itself.
Fix:
- move inline styles into CSS files
- avoid adding
'unsafe-inline'unless you really have no choice
“Refused to connect”
Usually not Monaco core. It’s your app, telemetry, or an external language service.
Fix:
- add the exact endpoint to
connect-src
My recommendation
If you want Monaco and a strong CSP without drama:
- self-host Monaco assets
- configure dedicated worker files
- use
worker-src 'self' - avoid
blob:if you can - avoid
unsafe-eval - keep
script-srcnonce-based with'strict-dynamic'
That gives you a policy that’s easy to reason about and doesn’t quietly erode over time.
Monaco works fine under a strict CSP. You just have to treat workers as a first-class CSP concern instead of an afterthought.