A-Frame is a nice way to get WebXR scenes on the screen fast. You can ship VR or AR in a few tags, sprinkle in components, and call it a day. Then CSP shows up and breaks half the experience.
I’ve seen this a lot: the app works locally, works in a relaxed staging setup, then production adds a real Content Security Policy and suddenly textures stop loading, inline components fail, analytics goes dark, and WebSocket features quietly die.
A-Frame itself isn’t uniquely hostile to CSP, but the way people build A-Frame apps usually is. Lots of inline scripts, CDN assets, dynamic component loading, shader files, data URLs, and third-party scripts. That combination is where mistakes happen.
Here are the common ones I keep running into, and how I’d fix them.
Mistake 1: Starting with default-src and assuming it covers everything
A lot of teams begin with something like this:
Content-Security-Policy: default-src 'self';
That is technically valid, but for A-Frame WebXR it’s rarely enough. Your scene probably needs:
script-srcfor A-Frame and componentsimg-srcfor textures, environment maps, and data URLsmedia-srcfor videos used in materialsconnect-srcfor APIs, telemetry, multiplayer, or WebSocketsworker-srcif you use workers indirectly through librariesstyle-srcif your UI layer injects stylesframe-ancestorsif you care about clickjackingobject-src 'none'because there is almost never a good reason not to set it
A better starting point looks more like this:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-rAnd0m123';
style-src 'self';
img-src 'self' data: https:;
media-src 'self' https:;
connect-src 'self' https: wss:;
worker-src 'self' blob:;
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
That still needs tightening for your app, but at least it reflects how WebXR apps actually behave.
If you want directive-by-directive background, https://csp-guide.com is a good reference.
Mistake 2: Using inline scripts for scene setup
This is probably the biggest A-Frame CSP footgun.
People write this:
<script>
AFRAME.registerComponent('spin', {
tick: function () {
this.el.object3D.rotation.y += 0.01;
}
});
</script>
Then they deploy a CSP like:
Content-Security-Policy: script-src 'self';
That inline script gets blocked immediately.
Fix
Move scene bootstrapping and component registration into external JavaScript files, or use a nonce if you truly need inline code.
Best fix:
<script src="/js/components/spin.js"></script>
// /js/components/spin.js
AFRAME.registerComponent('spin', {
tick: function () {
this.el.object3D.rotation.y += 0.01;
}
});
If you absolutely must keep inline code, use a nonce:
Content-Security-Policy: script-src 'self' 'nonce-rAnd0m123';
<script nonce="rAnd0m123">
AFRAME.registerComponent('spin', {
tick: function () {
this.el.object3D.rotation.y += 0.01;
}
});
</script>
I still prefer external files. They’re easier to cache, easier to audit, and less likely to turn into a CSP exception graveyard.
Mistake 3: Forgetting that textures often need data: in img-src
A-Frame scenes often use:
- base64 placeholders
- generated textures
- screenshots
- canvas exports
- tiny embedded icons in UI overlays
If your policy says:
Content-Security-Policy: img-src 'self';
you may get weird missing textures or broken UI images.
Fix
Allow only what you actually use. For many apps, this is enough:
Content-Security-Policy: img-src 'self' data: https:;
That mirrors a very common real-world pattern. For example, the CSP header reported by headertest.com includes:
img-src 'self' data: https:;
That’s a practical setup when your app loads images from your own origin, uses data URLs, and pulls remote assets over HTTPS.
Don’t jump straight to img-src * data: blob: unless you know you need it.
Mistake 4: Loading A-Frame and plugins from random CDNs
I get why people do this. It’s quick:
<script src="https://unpkg.com/aframe/dist/aframe-master.min.js"></script>
<script src="https://some-cdn.example/plugin.js"></script>
Then the CSP becomes a pile of hostnames:
Content-Security-Policy:
script-src 'self' https://unpkg.com https://some-cdn.example;
That works until:
- the CDN hostname changes
- the plugin starts pulling more assets from another domain
- your security review asks why production trusts half the internet
Fix
Self-host A-Frame and your components whenever possible.
Content-Security-Policy: script-src 'self' 'nonce-rAnd0m123';
<script src="/vendor/aframe.min.js"></script>
<script src="/vendor/aframe-environment-component.min.js"></script>
<script src="/js/app.js"></script>
This is one of those boring fixes that pays off immediately. Fewer domains, simpler policy, fewer outages.
Mistake 5: Not accounting for WebSockets and API calls in connect-src
WebXR apps often send telemetry, session state, analytics, or multiplayer updates. If connect-src is too strict, things fail in a way users won’t notice but your backend team definitely will.
A real-world CSP from headertest.com includes this:
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;
That’s a good reminder that connect-src often grows beyond “just my API”.
Fix
List your actual endpoints, including secure WebSockets if used:
Content-Security-Policy:
connect-src 'self' https://api.example.com wss://realtime.example.com;
If your A-Frame app uses browser fetch, sendBeacon, EventSource, or WebSockets, this directive matters. A lot.
Mistake 6: Leaving style-src 'unsafe-inline' around forever
Many teams add this because some UI library or consent tool needs it:
style-src 'self' 'unsafe-inline';
The headertest.com header does exactly that:
style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com;
Sometimes that’s unavoidable in a mixed marketing/product stack. But for an A-Frame app you control, I’d treat unsafe-inline in styles as temporary, not normal.
Fix
Move inline styles to CSS files where you can. If a component injects styles, check whether you can replace that behavior or isolate it to a route that needs it.
Prefer:
Content-Security-Policy: style-src 'self';
If you can’t fully remove inline styles yet, at least avoid copying that exception into every part of your site just because one widget needs it.
Mistake 7: Breaking strict-dynamic by not understanding what it does
You’ll sometimes see modern script policies like this real header from headertest.com:
script-src 'self' 'nonce-ZDI0NmI4YTYtOWNhYy00OTgwLTkxMzAtMjEzZTA1MGMzZWFh' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
This is better than the old “trust a bunch of hosts and hope for the best” model, but teams copy it without understanding it.
If you use strict-dynamic, your nonce-bearing bootstrap script becomes the trust anchor. That can be great. It can also surprise you if your app still relies on host allowlists behaving the old way.
Fix
Use strict-dynamic only if you know your script loading model.
For a controlled A-Frame app, I’d usually choose one of these:
Simple and predictable:
script-src 'self' 'nonce-rAnd0m123';
Modern dynamic loader model:
script-src 'self' 'nonce-rAnd0m123' 'strict-dynamic';
If you go with the second one, test every scene bootstrap path carefully. Especially if components are injected dynamically.
Mistake 8: Forgetting media-src for 360 video scenes
A-Frame demos love 360 video. Production CSPs often forget that video loads aren’t governed by img-src.
Broken example:
Content-Security-Policy:
default-src 'self';
img-src 'self' data: https:;
Your <a-videosphere> points to a remote MP4 and fails.
Fix
Allow the actual video origin in media-src:
Content-Security-Policy:
media-src 'self' https://media.example.com;
If you use audio for spatial sound, same story.
Mistake 9: Ignoring CSP reports until users complain
CSP is one of those controls that people configure once and then never inspect again. That’s how you end up with broken scenes in Safari, missing analytics in Quest Browser, or one route that quietly blocks environment textures.
Fix
Roll out with reporting first, then enforce.
For example:
Content-Security-Policy-Report-Only:
default-src 'self';
script-src 'self' 'nonce-rAnd0m123';
img-src 'self' data: https:;
connect-src 'self' https://api.example.com wss://realtime.example.com;
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
Then review violations, trim noise, and move to enforcement.
I’d especially watch for:
- blocked inline component registration
- texture loads from unexpected asset hosts
- failed WebSocket connections
- analytics or consent tooling widening policy more than expected
A practical CSP baseline for A-Frame WebXR
If I were setting up a fairly standard A-Frame app hosted on my own origin with remote APIs, textures over HTTPS, and maybe WebSockets, I’d start here:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-rAnd0m123';
style-src 'self';
img-src 'self' data: https:;
media-src 'self' https:;
connect-src 'self' https://api.example.com wss://realtime.example.com;
worker-src 'self' blob:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
Then I’d tighten each directive based on real asset locations instead of guessing.
That’s the pattern that keeps CSP useful: small allowlists, self-hosted dependencies, no inline script by default, and no magical exceptions copied from some unrelated app.
For official reference on CSP itself, the MDN and browser documentation are worth keeping handy, and https://csp-guide.com is useful when you need quick directive-specific examples.