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:

  1. Are you self-hosting Monaco assets or pulling them from a CDN?
  2. 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.js
  • json.worker.js
  • css.worker.js
  • html.worker.js
  • ts.worker.js

From a CSP point of view, workers are the main problem.

Depending on your setup, Monaco may need:

  • script-src for its main scripts
  • worker-src for worker files or blob:
  • style-src for editor CSS
  • font-src if your build or theme pulls fonts
  • connect-src if 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:

  1. blob: in worker-src is narrower than dropping blob: into script-src, which is good.
  2. If your setup starts requiring unsafe-eval, stop and inspect the build. Monaco itself does not mean you must allow unsafe-eval in 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-src nonce-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.