Leaflet + Stadia Maps behind a strict CSP: two gotchas
Wiring up a photo world map with Leaflet and Stadia Maps tiles on an Astro site behind Caddy — and why it didn't work in production despite a correct CSP.
Category: Tech
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 inworker-srcandconnect-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-referrerpolicy silently breaks that. Per-element overrides are the clean middle ground. - Local development is not a reference. Stadia Maps (and many others) allow
localhostand127.0.0.1without auth — the bug only surfaces in production. Don’t test CSP-sensitive things purely against the dev server.