CSP for Deck.gl: A Real-World Before and After
Deck.gl is one of those libraries that looks straightforward until you lock down your Content Security Policy and the map quietly dies in production.
I’ve seen this happen more than once: everything works locally, the app gets deployed behind a stricter CSP, and suddenly you’re staring at a blank canvas, a couple of cryptic console errors, and a team chat full of “did maps just break?”
This case study walks through a real-world pattern for getting Deck.gl working under CSP without giving up and slapping unsafe-eval onto the whole app.
The setup
The app was a single-page dashboard with:
- React
- Deck.gl
- MapLibre as the basemap
- A backend API for geospatial data
- Analytics and consent tooling
The original goal was sane enough:
- no inline scripts unless nonce-based
- no
unsafe-eval - no wild
*source lists - keep
connect-srctight - block object embeds entirely
That’s easy to say. Deck.gl and the surrounding WebGL stack can make it messy in practice, especially when workers enter the picture.
The “before” state: technically secure, practically broken
The team started with a CSP that was strict but incomplete for the app’s runtime behavior.
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-rAnd0m123' 'strict-dynamic';
style-src 'self';
img-src 'self' data:;
font-src 'self';
connect-src 'self' https://api.example.com;
worker-src 'self';
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none';
On paper, this looks respectable.
In reality, the page failed when Deck.gl initialized layers that depended on worker-based parsing and remote assets. The browser console showed errors like:
Refused to connect to 'https://tiles.example-cdn.com/...' because it violates the following Content Security Policy directive: "connect-src 'self' https://api.example.com".
Refused to create a worker from 'blob:https://app.example.com/...' because it violates the following Content Security Policy directive: "worker-src 'self'".
And sometimes, depending on the stack and bundler:
Refused to load the script 'https://unpkg.com/...' because it violates the following Content Security Policy directive: "script-src 'self' 'nonce-rAnd0m123' 'strict-dynamic'".
That last one usually came from a dependency or example code path nobody realized was still pulling from a CDN.
Why Deck.gl trips CSP
Deck.gl itself is not “bad for CSP,” but the ecosystem around it often requires a few things people forget to allow:
- Remote tile or data endpoints via
connect-src - Web workers via
worker-src - Blob workers in some parser and bundler setups via
blob: - Map style, glyph, sprite, or tile assets from separate domains
- Images for icons, textures, or basemap resources via
img-src
If you only think “Deck.gl is JavaScript, so I just need script-src,” you’ll miss most of the actual breakage.
For deeper directive behavior, CSP Guide is a good reference when you need the exact fallback rules.
The app code that exposed the problem
This was roughly the layer setup:
import {Deck} from '@deck.gl/core';
import {ScatterplotLayer} from '@deck.gl/layers';
import maplibregl from 'maplibre-gl';
const map = new maplibregl.Map({
container: 'map',
style: 'https://tiles.example-cdn.com/styles/dark.json',
center: [-122.4, 37.74],
zoom: 11
});
fetch('https://api.example.com/points')
.then((r) => r.json())
.then((data) => {
new Deck({
canvas: 'deck-canvas',
initialViewState: {
longitude: -122.4,
latitude: 37.74,
zoom: 11
},
controller: true,
layers: [
new ScatterplotLayer({
id: 'points',
data,
getPosition: (d) => d.coordinates,
getRadius: 40,
getFillColor: [0, 140, 255, 180]
})
]
});
});
Looks harmless. But that MapLibre style JSON pulled in:
- vector tiles from
https://tiles.example-cdn.com - glyphs from
https://fonts.example-cdn.com - sprites from
https://tiles.example-cdn.com
And one data pipeline used a workerized parser that resolved to a blob worker in production.
So the CSP wasn’t wrong. It just didn’t describe what the app actually did.
The debugging process
The fastest way to get unstuck was:
- open DevTools
- filter console for CSP violations
- list every blocked URL by directive
- map each one back to a real feature
That last step matters. Don’t just keep appending domains until the errors stop. Figure out whether each source is actually needed.
I also like testing headers with a tool like HeaderTest because it makes it easier to sanity-check whether the final policy still looks disciplined instead of turning into a copy-paste graveyard.
For reference, a real production-grade CSP can still be fairly tight. HeaderTest itself sends this:
content-security-policy: default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; script-src 'self' 'nonce-ZWE5ZTViZmUtMzNkNS00MzE0LThmMDUtZTg1MDc5YjVkNjhj' '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 the level of specificity I want: broad enough to run the site, narrow enough to explain itself.
The “after” state: strict, but actually usable
Here’s the revised CSP that fixed the Deck.gl app:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-rAnd0m123' 'strict-dynamic';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https://tiles.example-cdn.com https://sprites.example-cdn.com;
font-src 'self' https://fonts.example-cdn.com;
connect-src 'self'
https://api.example.com
https://tiles.example-cdn.com
https://events.example.com;
worker-src 'self' blob:;
child-src 'self' blob:;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none';
report-to csp-endpoint;
report-uri https://csp-report.example.com/report;
A few choices here were deliberate.
worker-src 'self' blob:
This was the big one. The app used workerized parsing in a way that resulted in blob-backed workers after bundling.
If your build emits dedicated worker files from your own origin, you may get away with just 'self'. But if the runtime creates workers from blob URLs, Deck.gl-adjacent tooling can fail unless blob: is allowed.
I don’t love allowing blob: casually, but for workers it’s often the practical fix.
connect-src includes tile endpoints
A lot of people forget that tile fetching is usually governed by connect-src, not just img-src.
If your basemap or data layers hit:
- style JSON
- vector tiles
- GeoJSON endpoints
- binary data endpoints
- telemetry APIs
they need to be listed here.
img-src and font-src cover map assets
Sprites, icons, marker images, and glyph resources often come from different hosts than your API. If your map style references them, CSP has to allow them.
style-src 'unsafe-inline'
I usually try to avoid this, but some UI stacks and map integrations make inline styles the path of least resistance. If you can replace this with nonces or hashes, do it. If not, at least keep the rest of the policy tight.
Before and after behavior
Before
- blank map canvas
- data layers never rendered
- worker initialization failed
- tile requests blocked
- repeated CSP console noise
After
- basemap loaded
- Deck.gl layers rendered normally
- workers initialized cleanly
- no CSP violations in normal user flows
- policy still rejected unknown scripts and embeds
That last part matters. The goal isn’t “make the errors disappear.” The goal is “allow only what the app truly needs.”
A production-friendly pattern
If I were shipping Deck.gl today, I’d start with something like this and tune from there:
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' https:;
connect-src 'self' https://api.example.com https://tiles.example-cdn.com;
worker-src 'self' blob:;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none';
Then I’d tighten hostnames one by one instead of leaving https: in place forever.
What I’d avoid
A few anti-patterns showed up during the fix.
1. Adding unsafe-eval without proof
Some teams jump straight to this when WebGL or heavy JS libraries misbehave. Most of the time, the real issue is workers, remote connections, or bundler output. Don’t weaken script-src unless you’ve confirmed it’s genuinely required.
2. Whitelisting random CDNs from example code
If a dependency is trying to load from unpkg, jsdelivr, or some mystery asset host in production, I’d treat that as a smell. Bundle it properly or self-host it.
3. Forgetting report collection
CSP without reporting is guesswork. Even a basic reporting endpoint helps catch the weird paths you didn’t test manually.
The practical takeaway
Deck.gl usually doesn’t need a wildly permissive CSP. It needs an accurate one.
The fix in this case was not “make security looser.” It was:
- allow the real tile and API endpoints
- permit worker execution in the way the bundle actually creates workers
- account for map asset hosts
- keep dangerous directives locked down
That’s the pattern I trust in production: strict by default, then explicit exceptions tied to observed behavior.
If your Deck.gl app goes blank after enabling CSP, I’d check connect-src and worker-src before anything else. That’s where the real breakage usually hides.