Setting Content-Security-Policy Headers for Embedded Folium Maps

Part of the Iframe Embedding & Isolation guide.

Operative rule: The frame-ancestors directive on the response that serves the map — not the parent page’s policy — decides who may embed it; set it to the exact origins of your dashboards and to nothing else.

How Content-Security-Policy Governs an Embedded Map

A Folium document is a complete HTML page: an inline <script> that boots Leaflet, <link> tags for Leaflet’s CSS, and runtime requests for map tiles from a provider like OpenStreetMap or CartoDB. When you serve that page and drop it into an <iframe>, two independent security surfaces meet. The parent page controls the sandbox attribute on the iframe element; the server that returns the map controls the Content-Security-Policy header on the response. A working embed needs both to agree. This division is the core of Iframe Embedding & Isolation: the parent decides how much the frame is trusted, the child declares what it needs to load and who is allowed to load it.

CSP is an allow-list. Every directive — script-src, style-src, img-src, connect-src, frame-ancestors — names the origins from which that resource class may be fetched, and the browser blocks anything not listed. Leaflet is unusually demanding here because it loads its runtime from a CDN, injects inline styles for markers, and streams raster tiles as images from a separate tile host. A CSP that is too tight silently breaks the map with no exception thrown; the tiles simply never appear. The one directive that does not describe a resource the map loads is frame-ancestors: it walks up the embedding chain and names the parent origins permitted to frame the document, superseding the older X-Frame-Options header. That is why it, and not the parent’s own policy, is the gate on embedding — a point that also shapes how you safely embed Folium maps in React dashboards.

CSP directives around an embedded Folium map A parent dashboard frames a Folium map document. The map response carries a CSP whose script-src, style-src and img-src allow the CDN and tile hosts, while frame-ancestors points back up to the permitted parent origin. Parent dashboard dashboard.example.com <iframe sandbox=...> frame-ancestors "may you embed me?" Folium map response map.html + CSP header inline Leaflet boot script-src / style-src img-src / connect-src CDN: Leaflet JS + CSS script-src / style-src allow Tile host PNG tiles img-src allow + data: Vector style JSON connect-src allow Each fetch is checked against the matching CSP directive; anything unlisted is silently blocked

Production-Ready Implementation

The folium map is generated once and written to disk, then a small Flask app serves it with a hardened policy. The Content-Security-Policy is assembled from a single source of truth so the tile host you configured on the map and the host you allow in img-src never drift apart. Leaflet’s boot script is emitted inline by Folium, so the policy uses a per-response nonce rather than 'unsafe-inline'.

from __future__ import annotations

import secrets

import folium
from flask import Flask, Response, render_template_string

app = Flask(__name__)

# Single source of truth: the tile provider the map actually uses.
TILE_HOST = "https://basemaps.cartocdn.com"
LEAFLET_CDN = "https://cdn.jsdelivr.net"          # Folium loads Leaflet from here
# Parent origins permitted to embed the map. Never use "*" here.
EMBEDDERS = ("https://dashboard.example.com",)


def build_map_html() -> str:
    """Render a Folium map whose tile host matches the CSP allow-list."""
    m = folium.Map(
        location=[40.7128, -74.0060],
        zoom_start=12,
        # Tiles must come from a host that img-src permits below.
        tiles="CartoDB positron",
    )
    folium.Marker([40.7128, -74.0060], tooltip="HQ").add_to(m)
    # get_root().render() returns the full standalone document as a string.
    return m.get_root().render()


def build_csp(nonce: str) -> str:
    """Assemble a CSP that lets Leaflet load while restricting embedders."""
    frame_ancestors = " ".join(EMBEDDERS) if EMBEDDERS else "'none'"
    directives = [
        "default-src 'none'",
        # Leaflet's runtime from the CDN + the inline boot script via nonce.
        f"script-src {LEAFLET_CDN} 'nonce-{nonce}'",
        # Leaflet CSS from the CDN + its injected inline marker styles.
        f"style-src {LEAFLET_CDN} 'unsafe-inline'",
        # Raster tiles + data: URIs for retina/marker images.
        f"img-src {TILE_HOST} data:",
        # Vector style.json / glyphs fetched at runtime.
        f"connect-src {TILE_HOST}",
        "font-src 'self' data:",
        # THE gate on embedding: only these parents may frame the map.
        f"frame-ancestors {frame_ancestors}",
        "base-uri 'none'",
        "form-action 'none'",
    ]
    return "; ".join(directives)


@app.route("/map")
def serve_map() -> Response:
    nonce = secrets.token_urlsafe(16)
    html = build_map_html()
    # Attach the nonce to Folium's inline <script> so script-src accepts it.
    html = html.replace("<script>", f'<script nonce="{nonce}">')

    response = Response(render_template_string(html))
    response.headers["Content-Security-Policy"] = build_csp(nonce)
    # Belt-and-braces for very old browsers that ignore frame-ancestors.
    response.headers["X-Frame-Options"] = "ALLOW-FROM https://dashboard.example.com"
    response.headers["X-Content-Type-Options"] = "nosniff"
    return response


if __name__ == "__main__":
    app.run(port=8000)

Alternative Variants

Serving the header from a static host or CDN edge

When the map is a pre-built static file — the output of exporting Folium maps to static HTML with embedded assets — there is no Flask process to run after_request. Attach the policy at the edge instead. Because the assets are inlined, the policy collapses to 'self' plus the tile host, and frame-ancestors still does the gatekeeping.

# Netlify _headers file (or the equivalent nginx add_header block)
/maps/*
  Content-Security-Policy: default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src https://basemaps.cartocdn.com data:; connect-src https://basemaps.cartocdn.com; font-src 'self' data:; frame-ancestors https://dashboard.example.com; base-uri 'none'
  X-Content-Type-Options: nosniff

CSP directive reference for a Folium embed

Directive Value for a CartoDB-tiled map What breaks if omitted
script-src CDN host + 'nonce-…' Leaflet never boots; blank white frame
style-src CDN host + 'unsafe-inline' Markers unstyled; controls misaligned
img-src tile host + data: Grey tile grid; no marker icons
connect-src tile/style host Vector style.json and glyphs fail
frame-ancestors your dashboard origins Any site can embed (or none can)
default-src 'none' Falls back to permissive *

The interplay with the parent’s sandbox attribute is additive: sandbox="allow-scripts" lets Leaflet execute, but the map’s own script-src still decides which script origins load. Grant the frame only allow-scripts (and allow-same-origin only if you genuinely need postMessage with same-origin semantics), and let the CSP handle the rest.

Verification Steps

  • Tiles render: open the framed map and confirm raster tiles fill the canvas rather than Leaflet’s grey grid — a grey grid means the tile host is missing from img-src.
  • No console violations: open DevTools → Console and reload; a Refused to load … because it violates the following Content-Security-Policy directive line names the exact directive to widen.
  • Embedding is gated: load the map inside a scratch page on a disallowed origin and confirm the browser refuses with a frame-ancestors violation; then load it from an allowed origin and confirm it renders.
  • Header actually present: run curl -sI https://your-host/map | grep -i content-security-policy and confirm the policy string is returned on the map response, not just the parent page.
  • Nonce rotates: reload twice and confirm the nonce- value differs between responses; a static nonce defeats the inline-script protection.

Common Errors & Fixes

Embedded Folium map shows a blank grey grid

Leaflet is running but every tile request is blocked. The tile provider host is absent from img-src, or the map’s configured tiles= provider resolves to a host you did not list. Fix: read the actual tile URL from the rendered document, then add that exact origin to img-src and include data: for retina and marker images. Keep the provider host in one constant shared by both the map builder and the policy so they cannot diverge.

Refused to frame … because an ancestor violates frame-ancestors

The parent origin embedding the map is not listed in the child’s frame-ancestors. This is the directive working as intended. Fix: add the parent’s exact scheme-plus-host (for example https://dashboard.example.com) to frame-ancestors on the map response. Do not try to fix this from the parent page — the child’s response is authoritative, which is why frame-ancestors is the operative gate.

Map renders locally but breaks once a strict CSP is added

The inline Leaflet boot script emitted by folium is being blocked by a script-src that lacks either a nonce or a hash. Adding 'unsafe-inline' would fix it but reopens an injection hole. Fix: inject a fresh per-response nonce into Folium’s <script> tag and list 'nonce-…' in script-src, exactly as the implementation above does.

Markers appear but are unstyled or the zoom control is misplaced

Leaflet injects inline style attributes and a small inline stylesheet for its controls. A style-src without 'unsafe-inline' strips them. Fix: allow 'unsafe-inline' in style-src (styles cannot execute code, so the risk is far lower than for scripts), or pre-extract Leaflet’s CSS to a file served from an allowed host and reference it there.