Content Security Policy in Python — Deep Dive
Nonce-based CSP: the gold standard
Nonces (number used once) allow specific inline scripts without the security hole of 'unsafe-inline'. Each page load generates a unique random nonce. The CSP header includes the nonce, and each legitimate inline script includes it as an attribute. Injected scripts won’t have the nonce and are blocked.
# Django middleware for nonce-based CSP
import secrets
from django.utils.deprecation import MiddlewareMixin
class CSPNonceMiddleware(MiddlewareMixin):
def process_request(self, request):
request.csp_nonce = secrets.token_urlsafe(16)
def process_response(self, request, response):
nonce = getattr(request, "csp_nonce", "")
csp = (
f"default-src 'self'; "
f"script-src 'self' 'nonce-{nonce}' https://cdn.example.com; "
f"style-src 'self' 'nonce-{nonce}'; "
f"img-src 'self' https: data:; "
f"connect-src 'self' https://api.example.com; "
f"frame-ancestors 'none'; "
f"base-uri 'self'; "
f"form-action 'self'"
)
response["Content-Security-Policy"] = csp
return response
In your Django template:
{% load static %}
<script nonce="{{ request.csp_nonce }}">
// This script executes because it has the matching nonce
console.log("Legitimate script");
</script>
<!-- An attacker's injected script has no nonce — blocked -->
<script>alert('xss')</script>
The django-csp package handles nonce generation and injection automatically. Use {% csp_nonce %} in templates after configuring the middleware.
Flask nonce implementation
from flask import Flask, g, render_template
from flask_talisman import Talisman
import secrets
app = Flask(__name__)
@app.before_request
def generate_nonce():
g.csp_nonce = secrets.token_urlsafe(16)
talisman = Talisman(
app,
content_security_policy={
"default-src": "'self'",
"script-src": lambda: f"'self' 'nonce-{g.csp_nonce}'",
"style-src": "'self' 'unsafe-inline'",
"img-src": "'self' https: data:",
},
content_security_policy_nonce_in=["script-src"],
)
Flask-Talisman injects the nonce automatically. In Jinja2 templates, access it via csp_nonce():
<script nonce="{{ csp_nonce() }}">
// Protected inline script
</script>
FastAPI nonce implementation
from fastapi import FastAPI, Request
from starlette.middleware.base import BaseHTTPMiddleware
import secrets
class CSPMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
nonce = secrets.token_urlsafe(16)
request.state.csp_nonce = nonce
response = await call_next(request)
csp_directives = [
"default-src 'self'",
f"script-src 'self' 'nonce-{nonce}'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' https: data:",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
]
response.headers["Content-Security-Policy"] = "; ".join(csp_directives)
return response
app = FastAPI()
app.add_middleware(CSPMiddleware)
@app.get("/page")
async def page(request: Request):
nonce = request.state.csp_nonce
return HTMLResponse(f"""
<html>
<script nonce="{nonce}">console.log("safe");</script>
</html>
""")
Hash-based CSP
For static inline scripts that don’t change between page loads, you can use hashes instead of nonces. Compute the SHA-256 hash of the script content and include it in the CSP:
import hashlib
import base64
script_content = 'console.log("analytics loaded");'
digest = hashlib.sha256(script_content.encode()).digest()
hash_value = base64.b64encode(digest).decode()
# CSP header includes: script-src 'sha256-<hash_value>'
This avoids the overhead of generating a nonce per request. The downside is that any change to the script content (even whitespace) changes the hash, requiring an update to the CSP header.
Violation reporting
CSP violations can be reported to a server endpoint for monitoring:
# Django endpoint for CSP reports
import json
import logging
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
logger = logging.getLogger("csp_reports")
@csrf_exempt
@require_POST
def csp_report(request):
try:
report = json.loads(request.body)
violation = report.get("csp-report", {})
logger.warning(
"CSP Violation",
extra={
"blocked_uri": violation.get("blocked-uri"),
"violated_directive": violation.get("violated-directive"),
"document_uri": violation.get("document-uri"),
"source_file": violation.get("source-file"),
"line_number": violation.get("line-number"),
},
)
except json.JSONDecodeError:
pass
return HttpResponse(status=204)
Add the report endpoint to your CSP:
Content-Security-Policy: ...; report-uri /csp-report
The newer report-to directive uses the Reporting API (a more general browser reporting mechanism), but report-uri has wider browser support today. Include both during the transition:
Content-Security-Policy: ...; report-uri /csp-report; report-to csp-endpoint
Reporting-Endpoints: csp-endpoint="/csp-report"
Migration strategy for legacy applications
Adding CSP to an existing application without breaking it requires a phased approach:
Phase 1: Audit. Deploy Content-Security-Policy-Report-Only with a permissive policy and reporting enabled. Collect violations for 1-2 weeks to understand what your app actually loads.
# Start permissive, report everything
csp = "default-src * 'unsafe-inline' 'unsafe-eval' data: blob:; report-uri /csp-report"
response["Content-Security-Policy-Report-Only"] = csp
Phase 2: Tighten. Based on reports, build a policy that covers your legitimate sources. Switch to report-only with the stricter policy.
# Tighter policy, still report-only
csp = (
"default-src 'self'; "
"script-src 'self' https://cdn.jsdelivr.net https://www.google-analytics.com; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' https: data:; "
"font-src 'self' https://fonts.gstatic.com; "
"connect-src 'self' https://api.myapp.com; "
"report-uri /csp-report"
)
Phase 3: Enforce. After a clean report period, switch from Report-Only to enforcing. Keep the report-uri active to catch edge cases.
Phase 4: Harden. Replace 'unsafe-inline' in script-src with nonces. Refactor inline event handlers (onclick, onload) to use addEventListener. Remove inline scripts and move them to external files.
Dealing with third-party scripts
Google Analytics, Stripe, Intercom, reCAPTCHA — third-party scripts are the biggest challenge for CSP. Each adds multiple domains and often injects additional scripts dynamically.
# Real-world CSP with common third-party services
csp_directives = {
"default-src": "'self'",
"script-src": " ".join([
"'self'",
"'nonce-{nonce}'",
"https://www.googletagmanager.com",
"https://www.google-analytics.com",
"https://js.stripe.com",
"https://www.recaptcha.net",
]),
"connect-src": " ".join([
"'self'",
"https://www.google-analytics.com",
"https://api.stripe.com",
]),
"frame-src": " ".join([
"https://js.stripe.com",
"https://www.recaptcha.net",
]),
"img-src": "'self' https: data:",
"style-src": "'self' 'unsafe-inline'",
"font-src": "'self' https://fonts.gstatic.com",
}
Document your third-party dependencies and their CSP requirements. When adding a new service, update the CSP before deploying the integration.
Strict CSP patterns
Google’s recommended “strict CSP” uses nonces with a fallback:
script-src 'nonce-{random}' 'strict-dynamic'
The 'strict-dynamic' keyword tells the browser: “Trust scripts loaded by already-trusted scripts.” If a nonced script dynamically creates another <script> element and adds it to the page, that new script is allowed. This handles third-party scripts that load additional scripts at runtime.
nonce = secrets.token_urlsafe(16)
csp = (
f"script-src 'nonce-{nonce}' 'strict-dynamic' https: 'unsafe-inline'; "
"object-src 'none'; "
"base-uri 'self'"
)
# 'unsafe-inline' and https: are fallbacks for older browsers
# that don't support nonces or strict-dynamic
In browsers that support nonces, 'unsafe-inline' is automatically ignored when a nonce is present. In older browsers that don’t support nonces, 'unsafe-inline' provides basic functionality (at the cost of weaker security).
Testing CSP
Automated tests should verify CSP headers are present and correct:
import pytest
def test_csp_header_present(client):
response = client.get("/")
assert "Content-Security-Policy" in response.headers
def test_csp_blocks_unsafe_inline_scripts(client):
response = client.get("/")
csp = response.headers["Content-Security-Policy"]
# script-src should NOT contain 'unsafe-inline' without nonce
if "'unsafe-inline'" in csp:
# If unsafe-inline is present, a nonce should also be present
assert "'nonce-" in csp, "unsafe-inline without nonce weakens CSP"
def test_csp_includes_frame_ancestors(client):
response = client.get("/")
csp = response.headers["Content-Security-Policy"]
assert "frame-ancestors" in csp
def test_csp_report_endpoint(client):
report = {"csp-report": {"blocked-uri": "https://evil.com/script.js"}}
response = client.post("/csp-report", json=report)
assert response.status_code == 204
Use Google’s CSP Evaluator (csp-evaluator.withgoogle.com) to check your policy for common weaknesses.
The one thing to remember: Deploy CSP in report-only mode first to avoid breaking your site, migrate inline scripts to nonce-based or external files, use 'strict-dynamic' for complex third-party integrations, and maintain a violation reporting endpoint to catch attacks and misconfigurations in production.
See Also
- Python Api Key Management Why apps use special passwords called API keys, and how to keep them safe — explained with a library card analogy
- Python Attribute Based Access Control How apps make fine-grained permission decisions based on who you are, what you're accessing, and the circumstances — explained with an airport analogy
- Python Audit Logging Learn Audit Logging with a clear mental model so your Python code is easier to trust and maintain.
- Python Bandit Security Scanning Why Bandit Security Scanning helps Python teams catch painful mistakes early without slowing daily development.
- Python Clickjacking Prevention How invisible website layers trick you into clicking the wrong thing, and how Python apps stop it