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)

A map document exercises an unusually wide set of CSP directives, because it fetches images, JSON, fonts and often inline styles from several origins at once. Knowing which directive governs which request makes the policy short and precise instead of permissive.

Which directive governs which map request Raster tiles and marker sprites are governed by img-src. Vector tiles, style documents and GeoJSON arrive over fetch and are governed by connect-src. The map library bundle is governed by script-src. Inline styles injected by the library need style-src. Web fonts used in labels need font-src. Whether the host may frame the map at all is governed by frame-ancestors on the map document and frame-src on the host. One map document, six directives img-src raster tiles · marker sprites · legend images · data: URIs if you inline icons connect-src vector tiles · style JSON · GeoJSON · anything fetched by XHR or fetch script-src the map library bundle · Folium's own inline init block needs a nonce or a hash style-src · font-src library CSS and injected inline styles · glyph fonts used for map labels frame-ancestors + frame-src the two-sided one — both documents must agree or the frame never renders

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.

Getting from a permissive policy to a tight one

Nobody writes a correct policy first time. The reliable route is to start in report-only mode, collect real violations from real sessions, and tighten in three passes.

Rolling out a CSP in three passes Pass one runs report-only with a report endpoint and breaks nothing while revealing every origin the map actually touches. Pass two enforces a policy built from those reports, still allowing whole hosts and unsafe-inline. Pass three replaces unsafe-inline with nonces, narrows hosts to exact paths and removes anything the reports never showed. Observe, enforce, tighten — never all at once 1 · report-only nothing is blocked every violation is logged run it for a full traffic cycle reveals origins no local test hits 2 · enforce, broadly built from the real reports whole hosts still allowed unsafe-inline still present keep the report endpoint live 3 · tighten nonces replace inline exact hosts and paths drop unused directives re-run the map's full UI Exercise every control before pass three: a layer toggle that lazily fetches a new source can be the one request the reports never saw. Keep report-only running alongside the enforced policy so the next upstream change shows up as a report, not an outage.

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.