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)
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.
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.
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