I’ve seen this pattern a lot: a team adds MapLibre GL JS to an otherwise locked-down site, ships to staging, and the map quietly explodes under Content Security Policy.
No tiles. No markers. Maybe the page itself works, but the console fills up with CSP errors about workers, styles, images, and network requests. Then somebody reaches for 'unsafe-inline' or loosens connect-src to https: and calls it a day.
That’s how good CSPs die.
Here’s a real-world case study for getting MapLibre GL JS working without trashing your policy.
The setup
The baseline site already had a serious CSP. A real header from headertest.com looks like this:
content-security-policy:
default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
script-src 'self' 'nonce-MjhkOGQ2MmUtNzcwYi00OTdkLTg1MjUtZGU2ZGJiMGI0ZWNm' '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 not perfect, but it’s fairly disciplined:
script-srcuses a nonce andstrict-dynamicobject-src 'none'frame-ancestors 'none'- no wildcard
*everywhere
Then the team adds MapLibre GL JS to render a store-locator map.
Basic frontend code looked like this:
<div id="map"></div>
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
const map = new maplibregl.Map({
container: 'map',
style: 'https://tiles.example.com/styles/bright/style.json',
center: [-0.1276, 51.5072],
zoom: 11
});
map.addControl(new maplibregl.NavigationControl());
Looks harmless. It wasn’t.
What broke
The browser console told the real story.
Typical failures looked like:
Refused to connect to 'https://tiles.example.com/styles/bright/style.json'
because it violates the following Content Security Policy directive:
"connect-src 'self' ..."
Then:
Refused to load the image 'https://tiles.example.com/sprites/sprite.png'
because it violates the following Content Security Policy directive:
"img-src 'self' data: https:"
That one actually passed in this case because img-src https: was already allowed, but many teams have tighter img-src and hit it immediately.
And the big one:
Refused to create a worker from 'blob:...'
because it violates the following Content Security Policy directive:
"script-src ..."
MapLibre GL JS uses web workers for rendering and tile processing. If your CSP doesn’t account for that, the map won’t initialize properly.
The lazy fix people try
I’ve seen teams “fix” this with something like:
Content-Security-Policy:
default-src 'self' https: data: blob:;
script-src 'self' 'unsafe-inline' 'unsafe-eval' https: blob:;
style-src 'self' 'unsafe-inline' https:;
img-src * data: blob:;
connect-src *;
worker-src blob:;
Yes, the map works. So would half the malware on the internet.
This is the wrong move for two reasons:
- It abandons the trust model you already had.
- It makes future review impossible because everything is allowed.
If you already bothered to use nonces and strict-dynamic, don’t throw that away because one map library needs a worker.
The better approach
You need to inventory what MapLibre actually does on your page.
In the real deployment, the app needed:
- the MapLibre library script from self-hosted assets
- the MapLibre CSS from self-hosted assets
- style JSON from the tile provider
- vector tiles from the tile provider
- glyphs from the tile provider
- sprite JSON and PNG from the tile provider
- worker execution for MapLibre
- optional marker images from your own CDN
That maps pretty cleanly to CSP directives:
script-srcfor the library itselfstyle-srcfor CSSconnect-srcfor style JSON, tiles, glyphs, sprite JSON, API callsimg-srcfor sprite PNGs, marker images, data URLsworker-srcfor workers- sometimes
child-srcfor older browser behavior - maybe
font-srcif your app loads custom fonts outside the map
If you want a good breakdown of directives, https://csp-guide.com is a solid reference.
Before: the existing policy
Here’s the simplified starting point, based on the real header above:
Content-Security-Policy:
default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
script-src 'self' 'nonce-r4nd0m' '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 policy had no explicit worker allowance and no tile backend in connect-src.
After: a CSP that works with MapLibre GL JS
The production-safe version added only what the map needed.
Content-Security-Policy:
default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
script-src 'self' 'nonce-r4nd0m' '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 https://tiles.example.com;
worker-src 'self' blob:;
child-src 'self' blob:;
frame-src 'self' https://consentcdn.cookiebot.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none';
That was enough for this specific setup.
A few details matter here.
connect-src needs the tile origin
MapLibre fetches a lot over XHR/fetch:
- style JSON
- vector or raster tiles
- glyphs
- sprite metadata
Those are governed by connect-src, not just img-src.
If your style file references multiple hosts, you need all of them. A very common mistake is allowing only the style host and forgetting the glyph or tile subdomain.
For example:
{
"glyphs": "https://fonts.example.com/{fontstack}/{range}.pbf",
"sources": {
"osm": {
"type": "vector",
"tiles": ["https://tiles.example.com/data/v3/{z}/{x}/{y}.pbf"]
}
},
"sprite": "https://assets.example.com/maps/sprite"
}
That would require at least:
connect-src ... https://tiles.example.com https://fonts.example.com https://assets.example.com;
img-src ... https://assets.example.com;
worker-src is the MapLibre gotcha
If you remember one thing, make it this: MapLibre GL JS and CSP usually collide at web workers.
Modern CSP uses worker-src. In practice, I still add child-src for compatibility with older implementations if I care about wider browser support.
worker-src 'self' blob:;
child-src 'self' blob:;
Why blob:? Because many builds of MapLibre create worker URLs using blobs.
If your integration uses the CSP-specific worker build or a self-hosted worker file, you may be able to avoid blob:. That’s cleaner when possible.
MapLibre documents CSP-compatible options in the official docs: https://maplibre.org/maplibre-gl-js/docs/
A stricter option: self-host the worker
If you want to avoid blob: in worker-src, use a CSP-friendly worker setup.
Example:
import maplibregl from 'maplibre-gl';
import MapLibreWorker from 'maplibre-gl/dist/maplibre-gl-csp-worker';
maplibregl.setWorkerClass(MapLibreWorker);
const map = new maplibregl.Map({
container: 'map',
style: '/map-style.json',
center: [-0.1276, 51.5072],
zoom: 11
});
Then your policy can often be tightened to:
worker-src 'self';
child-src 'self';
I prefer this when the app is security-sensitive. Fewer special schemes means fewer surprises during audits.
The CSS problem nobody talks about
Map libraries often tempt people into leaving style-src 'unsafe-inline' forever.
In this case, the site already had 'unsafe-inline' for unrelated consent tooling, so MapLibre didn’t make that worse. But if you’re starting fresh, don’t assume the map requires inline styles. Usually the real issue is your app or a third-party tag manager, not MapLibre itself.
A cleaner setup looks like:
style-src 'self' 'nonce-r4nd0m';
Or just self-hosted stylesheets if your app doesn’t inject inline style blocks.
A real debugging workflow
When I do this for real, I don’t guess. I use this sequence:
- Start from the existing CSP.
- Turn on
Content-Security-Policy-Report-Only. - Load the map and interact with it.
- Watch the browser console and violation reports.
- Add exact origins, not wildcards.
- Move the final policy to enforcing mode.
A report-only header for testing might look like:
Content-Security-Policy-Report-Only:
default-src 'self';
script-src 'self' 'nonce-r4nd0m' 'strict-dynamic';
style-src 'self';
img-src 'self' data:;
connect-src 'self' https://tiles.example.com https://fonts.example.com https://assets.example.com;
worker-src 'self' blob:;
child-src 'self' blob:;
object-src 'none';
base-uri 'self';
form-action 'self';
report-to csp-endpoint;
That gives you a clean diff between what the app needs and what the map needs.
What changed in practice
Before the fix:
- map failed to initialize
- CSP violations for workers and tile requests
- developers considered broad
https:andblob:allowances everywhere
After the fix:
- map loaded normally
- existing nonce-based script policy stayed intact
- analytics and consent tooling remained isolated
- only one new network origin was added
- worker permissions were explicit instead of accidental
That’s the whole game with CSP: small, deliberate allowances.
The final recommendation
If you’re adding MapLibre GL JS to a site with a serious CSP, do this:
- self-host MapLibre assets when possible
- inspect the style JSON for every external origin it references
- allow those origins in
connect-src - allow sprite/image origins in
img-src - add
worker-src, and use a CSP-compatible worker build if you want to avoidblob: - keep
script-srctight; don’t add'unsafe-eval'because a map broke - use report-only mode first
A good CSP for MapLibre isn’t hard. The hard part is resisting the urge to “just make it work” with giant wildcards.
That shortcut always comes back to bite.