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.

pythonsecurityweb

See Also