I’ve seen the same pattern a few times with Three.js projects: the 3D demo works, the product team loves it, then someone turns on a real Content Security Policy and half the scene stops loading.

The root problem usually is not Three.js itself. It’s everything wrapped around it: inline bootstrapping scripts, shader loading, texture CDNs, analytics, WebSocket dev tooling, model fetches, and a build pipeline that quietly assumes permissive browser behavior.

Here’s a real-world style case study for a Three.js app, with the kind of “before” and “after” CSP I’d actually expect to see in production.

The setup

A team had a marketing site with a hero WebGL scene built in Three.js:

  • three.module.js bundled with Vite
  • GLTF model loaded at runtime
  • HDR environment map
  • textures from a CDN
  • analytics and consent tooling
  • some inline config injected by the server
  • a WebSocket connection for a live product configurator

They started with a CSP that was basically there for compliance, not protection.

Before: the fake-safe CSP

This was close to what they shipped initially:

Content-Security-Policy:
  default-src * 'unsafe-inline' 'unsafe-eval' data: blob:;
  script-src * 'unsafe-inline' 'unsafe-eval';
  style-src * 'unsafe-inline';
  img-src * data: blob:;
  connect-src *;
  worker-src * blob:;
  frame-src *;

That policy “worked” because it allowed almost everything. It also wiped out most of the value of CSP.

The app code looked pretty normal:

<div id="app"></div>

<script>
  window.APP_CONFIG = {
    modelUrl: "https://cdn.example-cdn.com/models/chair.glb",
    envMapUrl: "https://cdn.example-cdn.com/hdr/studio.hdr",
    socketUrl: "wss://live.example.com/session"
  };
</script>

<script type="module" src="/assets/main.js"></script>

And the Three.js bootstrap:

import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader.js';

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ antialias: true });

document.getElementById('app').appendChild(renderer.domElement);

const config = window.APP_CONFIG;

new RGBELoader().load(config.envMapUrl, (hdr) => {
  hdr.mapping = THREE.EquirectangularReflectionMapping;
  scene.environment = hdr;
});

new GLTFLoader().load(config.modelUrl, (gltf) => {
  scene.add(gltf.scene);
});

const socket = new WebSocket(config.socketUrl);
socket.addEventListener('message', (event) => {
  console.log('live update', event.data);
});

No obvious red flags from a frontend perspective. But from a CSP perspective, there were several:

  1. Inline script for APP_CONFIG
  2. Wildcard sources everywhere
  3. unsafe-eval left on “just in case”
  4. No restriction on where models, textures, or sockets could connect
  5. No object-src 'none', no base-uri, no frame-ancestors

That’s the sort of policy that passes a checkbox and fails the job.

The trigger

Security asked them to tighten CSP before launch. They did the classic first pass:

Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  style-src 'self';
  img-src 'self';
  connect-src 'self';

And the app broke immediately.

What failed

Browser console errors looked like this:

Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self'".

Refused to connect to 'https://cdn.example-cdn.com/models/chair.glb' because it violates the following Content Security Policy directive: "connect-src 'self'".

Refused to connect to 'wss://live.example.com/session' because it violates the following Content Security Policy directive: "connect-src 'self'".

Refused to load the image 'https://cdn.example-cdn.com/textures/fabric_basecolor.jpg' because it violates the following Content Security Policy directive: "img-src 'self'".

This is where people often blame Three.js. I wouldn’t. Three.js was fine. The policy just didn’t reflect how the app actually worked.

The investigation

They ran the site through HeaderTest to inspect headers and compare what was actually being sent. That also helped them sanity-check their direction against a real deployed CSP.

For example, HeaderTest sends a production CSP like this:

content-security-policy: default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; script-src 'self' 'nonce-ODg4NmI0YTYtN2M4Ni00OTM0LWE3ZDAtNmM1MWNkZWI2NGY2' '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 useful reference because it shows what a practical CSP looks like in the real world: specific sources, nonce-based scripts, explicit connect-src, and hardening directives like object-src 'none' and frame-ancestors 'none'.

The fix

They ended up changing both the app and the policy.

Step 1: remove the inline config script

Instead of injecting config inline, they moved it into a JSON endpoint or encoded it into a safe DOM element.

Before:

<script>
  window.APP_CONFIG = {
    modelUrl: "https://cdn.example-cdn.com/models/chair.glb"
  };
</script>

After:

<script type="module" src="/assets/main.js"></script>
const config = await fetch('/api/webgl-config', {
  credentials: 'same-origin'
}).then(r => r.json());

That one change avoided the pressure to add 'unsafe-inline' to script-src.

If you do need server-side inline bootstrapping, use nonces. If you want the details on nonce-based policies and strict-dynamic, csp-guide.com is a solid reference.

Step 2: map every runtime fetch

Three.js loaders usually hit connect-src, not img-src, when they fetch models or HDR files via XHR/fetch under the hood.

That trips people up.

They listed every actual resource origin:

  • app JS from 'self'
  • models from https://cdn.example-cdn.com
  • HDR files from https://cdn.example-cdn.com
  • textures from https://cdn.example-cdn.com
  • live updates from wss://live.example.com
  • analytics from https://www.googletagmanager.com and https://www.google-analytics.com

Step 3: tighten the CSP to match reality

Their final policy looked more like this:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
  style-src 'self';
  img-src 'self' data: https://cdn.example-cdn.com;
  font-src 'self';
  connect-src 'self' https://cdn.example-cdn.com wss://live.example.com https://www.google-analytics.com https://www.googletagmanager.com;
  worker-src 'self' blob:;
  frame-src 'self';
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';

A few opinions here:

  • I avoid 'unsafe-eval' unless I’ve proven I need it.
  • I avoid wildcard CDNs unless there’s a very good reason.
  • I’d rather explicitly list connect-src origins than debug weird loader failures later.
  • worker-src blob: can be necessary if your rendering stack or tooling uses blob workers. Don’t add it blindly, but don’t be shocked if you need it.

After: the app code under a real CSP

The updated boot code was simple and CSP-friendly:

import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader.js';

const config = await fetch('/api/webgl-config', {
  credentials: 'same-origin'
}).then((r) => r.json());

const renderer = new THREE.WebGLRenderer({ antialias: true });
document.getElementById('app').appendChild(renderer.domElement);

const scene = new THREE.Scene();

const rgbeLoader = new RGBELoader();
const gltfLoader = new GLTFLoader();

const hdr = await rgbeLoader.loadAsync(config.envMapUrl);
hdr.mapping = THREE.EquirectangularReflectionMapping;
scene.environment = hdr;

const gltf = await gltfLoader.loadAsync(config.modelUrl);
scene.add(gltf.scene);

const socket = new WebSocket(config.socketUrl);
socket.addEventListener('message', handleLiveUpdate);

And if they needed a tiny inline script, they used a nonce:

<script nonce="{{ .CSPNonce }}">
  window.NON_SENSITIVE_FLAGS = { debugUI: false };
</script>

With matching header:

script-src 'self' 'nonce-{{NONCE}}' 'strict-dynamic';

What they learned

1. Three.js loaders are where CSP pain usually shows up

GLTF, DRACO, KTX2, HDR, audio, textures, video textures — all of these can introduce fetches from different origins. If you only think about <script> tags, you’ll miss the real breakpoints.

2. connect-src matters more than people expect

For modern JS apps, especially WebGL apps, connect-src is often the directive that decides whether the scene loads at all.

Models fetched through loaders, API calls, telemetry, live collaboration, WebSockets — it all lands there.

3. Inline convenience becomes policy debt

That little window.APP_CONFIG = {...} block seems harmless until it forces 'unsafe-inline' or a rushed nonce implementation. I’ve learned to remove inline script early if I know CSP is coming.

4. Start with Report-Only

If this were a larger production rollout, I’d start with:

Content-Security-Policy-Report-Only:
  default-src 'self';
  ...

Then collect violations, fix them, and only enforce when the noise drops. That’s especially useful for WebGL apps where asset loading paths can be surprisingly messy.

A practical baseline for Three.js CSP

If you’re building a typical Three.js app, this is a decent starting point:

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

Then narrow https: and wss: down to exact origins once you know your asset and API map.

That’s the real move: not chasing a perfect CSP on day one, but replacing a fake-safe policy with one that matches how your Three.js app actually behaves. Once you do that, WebGL and CSP get along just fine.