Leaflet looks simple until you lock down your site with Content Security Policy. Then the map goes blank, markers disappear, plugin styles break, and you end up staring at DevTools wondering why a harmless map library suddenly needs half the internet.

I’ve hit this enough times that I treat Leaflet as a CSP integration task, not just a UI widget. The good news: most failures come from a handful of repeat mistakes.

Mistake #1: Allowing Leaflet itself but forgetting the tile server

This is the one that gets everybody first.

You load Leaflet from your own origin or a trusted CDN, the JavaScript runs fine, the map container renders, and then no tiles load. You just get a gray box.

That happens because Leaflet doesn’t ship the actual map images. Those come from a tile provider like OpenStreetMap, MapTiler, Mapbox, or your own tile endpoint. CSP sees those as images, so img-src has to allow them.

A lot of teams start from a policy like this real-world example from headertest.com:

Content-Security-Policy:
  default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
  script-src 'self' 'nonce-NzU0Zjk2YjQtZjQ1Yi00NzZjLThiY2UtMjBhNzgzODVjYjRi' '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 img-src 'self' data: https: will usually make Leaflet tiles work because it broadly allows HTTPS images. But I don’t love broad https: if you know exactly which tile host you use.

Better fix

Be explicit:

Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  style-src 'self';
  img-src 'self' data: https://tile.openstreetmap.org;
  object-src 'none';
  base-uri 'self';

If you use multiple subdomains for tiles, include them:

img-src 'self' data: https://a.tile.openstreetmap.org https://b.tile.openstreetmap.org https://c.tile.openstreetmap.org;

If you want a refresher on how fetch directives fall back to default-src, the docs at https://csp-guide.com/default-src/ are worth keeping handy.

Mistake #2: Forgetting marker icons are also images

Leaflet’s default marker icons are separate image files. So even if your tiles load, your markers can silently fail if the icon URLs are blocked.

Typical symptom: map renders, tiles render, but markers are invisible.

Leaflet’s defaults usually reference files like:

  • marker-icon.png
  • marker-icon-2x.png
  • marker-shadow.png

If those files are served from your own app, img-src 'self' covers them. If your bundler rewrites paths in weird ways, or you serve assets from a CDN, you need to allow that source too.

Broken setup

L.marker([51.5, -0.09]).addTo(map);

With CSP:

img-src 'self' https://tile.openstreetmap.org;

If marker assets come from https://cdn.example.com, they’ll be blocked.

Fix

img-src 'self' data: https://tile.openstreetmap.org https://cdn.example.com;

Or avoid surprise network requests and set the icon URLs yourself:

import L from "leaflet";
import marker2x from "leaflet/dist/images/marker-icon-2x.png";
import marker from "leaflet/dist/images/marker-icon.png";
import shadow from "leaflet/dist/images/marker-shadow.png";

delete L.Icon.Default.prototype._getIconUrl;

L.Icon.Default.mergeOptions({
  iconRetinaUrl: marker2x,
  iconUrl: marker,
  shadowUrl: shadow,
});

I prefer this in production anyway. It makes asset paths predictable, which makes CSP easier.

Mistake #3: Blocking Leaflet CSS or plugin CSS

Leaflet without CSS is a mess. Controls stack wrong, markers misalign, popups look broken, and touch interactions get weird.

People often remember script-src and img-src, then forget style-src.

Broken policy

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

That only works if:

  1. leaflet.css is hosted on your own origin
  2. you don’t use inline styles
  3. your plugins don’t inject styles

The headertest.com policy uses:

style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com;

That’s common in the real world, but if you’re tightening CSP, unsafe-inline is usually the part you want to get rid of.

Fix

Serve Leaflet CSS from your own origin if possible:

style-src 'self';

And include it normally:

<link rel="stylesheet" href="/assets/leaflet/leaflet.css">

If you absolutely must use a CDN-hosted stylesheet, add that exact origin to style-src.

Mistake #4: Using inline map setup without a nonce or hash

A lot of Leaflet examples look like this:

<div id="map"></div>
<script>
  const map = L.map('map').setView([51.505, -0.09], 13);

  L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
    maxZoom: 19,
    attribution: '&copy; OpenStreetMap contributors'
  }).addTo(map);
</script>

That’s fine until your CSP blocks inline scripts.

If your policy is nonce-based, like the headertest.com example:

script-src 'self' 'nonce-NzU0Zjk2YjQtZjQ1Yi00NzZjLThiY2UtMjBhNzgzODVjYjRi' 'strict-dynamic';

then your inline Leaflet setup needs the matching nonce.

Fix with nonce

<div id="map"></div>
<script nonce="{{ .CSPNonce }}">
  const map = L.map('map').setView([51.505, -0.09], 13);

  L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
    maxZoom: 19,
    attribution: '&copy; OpenStreetMap contributors'
  }).addTo(map);
</script>

Better yet, move it into an external file:

<script src="/js/map.js"></script>

That’s cleaner and usually easier to maintain than chasing inline script hashes.

For nonce and hash details, https://csp-guide.com/script-src/ is the page I’d point people to.

Mistake #5: Assuming all map data comes through img-src

Not always.

Classic raster tiles use img-src. But some Leaflet setups fetch GeoJSON, vector tiles, routing data, geocoding responses, or custom overlays over XHR/fetch. Those need connect-src.

Typical example:

fetch("/api/places")
  .then((res) => res.json())
  .then((places) => {
    L.geoJSON(places).addTo(map);
  });

If that API is cross-origin, connect-src has to allow it.

Fix

connect-src 'self' https://api.example.com;

If you use WebSockets for live location updates:

connect-src 'self' https://api.example.com wss://ws.example.com;

I’ve seen teams waste time tweaking img-src for GeoJSON bugs. Wrong directive. If it’s fetch, XHR, EventSource, or WebSocket, check connect-src.

Mistake #6: Allowing https: for images because it “just works”

This one is less a bug and more a lazy shortcut.

Yes, this works:

img-src 'self' data: https:;

That’s what the headertest.com policy does, and for a general-purpose app it may be a practical tradeoff. But for Leaflet maps, it usually means you’re allowing every HTTPS image on the internet just to load tiles and marker assets.

That’s wider than most apps need.

Better fix

List the exact hosts:

img-src 'self' data:
  https://tile.openstreetmap.org
  https://cdn.example.com;

If your map provider rotates across subdomains, allow those specific subdomains or a scoped wildcard when necessary:

img-src 'self' data: https://*.tiles.example.com;

Scoped wildcard beats https: every time.

Mistake #7: Third-party Leaflet plugins sneaking in extra CSP requirements

Leaflet plugins are where clean CSP setups go to die.

A clustering plugin may be fine. A geocoder plugin may call a remote API. A fullscreen plugin may be harmless. A drawing plugin may inject inline styles. A heatmap plugin may use data URLs or canvas exports.

Don’t assume “it’s a Leaflet plugin” means the same CSP rules apply.

Fix

Audit each plugin by resource type:

  • JavaScript file: script-src
  • CSS file: style-src
  • Tile or icon images: img-src
  • API requests: connect-src
  • Fonts: font-src
  • Worker usage: maybe worker-src

My rule: add one plugin at a time with CSP report-only enabled first, then tighten after you see what it actually needs.

Mistake #8: Forgetting data: for generated images

Some Leaflet extensions or custom marker strategies use base64/data URL images. If your CSP blocks data: in img-src, those markers or overlays may disappear.

Fix

img-src 'self' data: https://tile.openstreetmap.org;

I don’t add data: automatically everywhere, but for image-heavy map UIs it’s often necessary.

A practical CSP baseline for Leaflet

If I were starting from scratch with self-hosted Leaflet assets, OpenStreetMap tiles, local marker images, and an API for GeoJSON, I’d use something like this:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
  style-src 'self';
  img-src 'self' data: https://tile.openstreetmap.org;
  connect-src 'self' https://api.example.com;
  font-src 'self';
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';

And if you still have some unavoidable inline styles from legacy UI code, I’d isolate and remove those instead of immediately reaching for style-src 'unsafe-inline'.

What I check first when a Leaflet map breaks under CSP

My debugging order is always the same:

  1. Blank map tilesimg-src
  2. Missing markersimg-src for marker assets or data:
  3. Broken layout/controlsstyle-src
  4. Map init script blockedscript-src nonce/hash
  5. GeoJSON or search not loadingconnect-src
  6. Only plugin features failing → inspect that plugin’s network and DOM behavior

Leaflet itself is not especially hostile to CSP. The pain usually comes from asset paths, tile hosts, inline setup scripts, and plugins that quietly pull in more dependencies than you expected.

If you treat the map as a bundle of resource types instead of “one library,” CSP gets a lot easier.