Leaflet + Stadia Maps behind a strict CSP: two gotchas

Category: Tech

Tags: leaflet, maps, csp, caddy, astro, security


For the photo section of this site I wanted a world map showing every shot location derived from the EXIF coordinates of my photos. The tech was unexciting: Leaflet plus leaflet.markercluster, tiles from Stadia Maps, markers built from the sidecar JSONs I already keep next to each photo. Astro renders the page statically; the client script boots the map on load.

It worked on the first try locally. In production it didn’t — just a grey rectangle with markers floating on top. Two independent problems, both caused by the security headers I’d set up in Caddy.

Problem 1: img-src 'self' blocks the tiles

The CSP in Caddy was deliberately strict:

Content-Security-Policy "default-src 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; font-src 'self'; connect-src 'self'; ..."

Leaflet loads tiles as plain <img> elements — so img-src 'self' data: blocks anything that isn’t my own domain.

Fix: whitelist the Stadia Maps host explicitly in img-src. On my box the per-site config lives at /etc/caddy/sites/adrian-altner.de.caddy:

- img-src 'self' data:;
+ img-src 'self' data: https://tiles-eu.stadiamaps.com;

connect-src doesn’t need to change — Leaflet uses <img> tags for tiles, not fetch or XHR.

Roll it out (backup → validate → reload):

sudo cp /etc/caddy/sites/adrian-altner.de.caddy \
        /etc/caddy/sites/adrian-altner.de.caddy.pre-csp-tiles
sudo vim /etc/caddy/sites/adrian-altner.de.caddy   # patch the img-src line
sudo caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
sudo systemctl reload caddy

caddy validate parses the full config without touching the running service — typos fail here instead of taking down the reload.

Problem 2: Referrer-Policy "no-referrer" produces 401

After reloading Caddy the map loaded — but the tiles showed only QR codes and “401 Error · Invalid Authentication · Learn more at docs.stadiamaps.com/authentication”. Stadia Maps uses the Referer header for domain-based auth: it checks which host the request is coming from. That’s exactly the header my global Referrer-Policy "no-referrer" was stripping.

I didn’t want to weaken the global policy (it’s there for GDPR reasons). So I needed an exception just for the tile requests. The clean way: the referrerpolicy attribute directly on each <img> — it overrides the document-level policy.

Leaflet’s TileLayer has supported that option since 1.9:

L.tileLayer('https://tiles-eu.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png', {
  attribution: '…',
  maxZoom: 20,
  referrerPolicy: 'origin',
}).addTo(map);

'origin' sends only the origin (https://adrian-altner.de), no paths, no query strings — enough for Stadia Maps to verify the domain, still minimal enough to keep the spirit of the global policy.

What I take away

  • CSP exceptions for third-party maps are unavoidable. As soon as map services serve tiles or glyphs, you’ll need their host in img-src (and for WebGL-based libraries like MapLibre, also in worker-src and connect-src).
  • Referrer-Policy and Referer-based auth don’t mix. Services like Stadia Maps, Mapbox or Google Maps use the Referer as a domain check. A global no-referrer policy silently breaks that. Per-element overrides are the clean middle ground.
  • Local development is not a reference. Stadia Maps (and many others) allow localhost and 127.0.0.1 without auth — the bug only surfaces in production. Don’t test CSP-sensitive things purely against the dev server.