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.
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 directiveline 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-ancestorsviolation; 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-policyand 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.
Related
- Iframe Embedding & Isolation — parent guide covering sandboxing, origins, and the full embedding boundary
- Safely Embedding Folium Maps in React Dashboards — the sandbox and Blob-URL side of the same isolation boundary the CSP protects
- Auto-Resizing Embedded Maps with postMessage — the cross-origin channel that a strict
connect-srcandframe-ancestorsmust still permit - Exporting Folium Maps to Static HTML with Embedded Assets — inlining assets so the served CSP can collapse to
'self'plus the tile host