Content Security Policy is one of those headers teams keep meaning to add, then postpone until they hit an XSS bug or a compliance checklist. I’ve seen both. The good news: Django and Flask make CSP pretty manageable once you stop treating it like a giant scary string.
This guide shows how to wire up CSP in both frameworks, how to handle nonces, and how to avoid the usual mistakes that turn your “secure” policy into unsafe-inline soup.
What CSP actually does
CSP tells the browser what sources are allowed for scripts, styles, images, AJAX requests, frames, fonts, and more.
A basic policy might look like this:
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'
That means:
- only load resources from the same origin by default
- only run scripts from the same origin
- block old plugin content like Flash with
object-src 'none' - prevent
<base>tag abuse - prevent the site from being embedded in iframes
CSP is mostly about reducing the blast radius of XSS. It does not replace output escaping, template safety, or input validation. It gives the browser a second opinion.
Start with Report-Only
My default advice: deploy CSP in report-only mode first.
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'
That lets you see what would break without actually breaking it.
Once the noise settles, switch to the enforcing header:
Content-Security-Policy: ...
A real policy from production
Here’s a real CSP header observed from headertest.com:
content-security-policy: default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; script-src 'self' 'nonce-MTY2Zjc3NDEtYWE5NS00NDc4LWE1MjktOWViMTcwN2QwMTQ0' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://api.headertest.com https://tallycdn.com https://or.headertest.com wss://or.headertest.com https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com; frame-src 'self' https://consentcdn.cookiebot.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'
This is a pretty realistic modern policy:
default-src 'self'as the baseline- third-party analytics and consent providers explicitly allowed
script-srcuses a nonce andstrict-dynamicstyle-srcstill has'unsafe-inline', which is common but not idealconnect-srcallows analytics endpoints and WebSocket trafficframe-ancestors 'none'blocks clickjackingobject-src 'none'is the easy win everyone should set
If you want ready-to-use policy examples, https://csp-examples.com is useful for comparing patterns.
A sane starter policy
For many server-rendered Django or Flask apps, this is a solid starting point:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{RANDOM_NONCE}';
style-src 'self';
img-src 'self' data: https:;
font-src 'self';
connect-src 'self';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
object-src 'none';
If your templates still use inline styles or scripts, the browser will block them. Good. That pain usually reveals old shortcuts that should have been cleaned up years ago.
CSP in Django
Django has a couple of good options:
- set headers yourself with middleware
- use a package like
django-csp - generate nonces per request and expose them to templates
I like explicit middleware because it makes the moving parts obvious.
Basic middleware
Create a middleware that adds a CSP header:
# myapp/middleware.py
class ContentSecurityPolicyMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
policy = (
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self'; "
"img-src 'self' data: https:; "
"font-src 'self'; "
"connect-src 'self'; "
"base-uri 'self'; "
"form-action 'self'; "
"frame-ancestors 'none'; "
"object-src 'none'"
)
response["Content-Security-Policy"] = policy
return response
Then register it:
# settings.py
MIDDLEWARE = [
# ...
"myapp.middleware.ContentSecurityPolicyMiddleware",
]
That works fine until you need inline script support via nonces.
Django CSP with nonces
Generate a nonce for each request, attach it to the request object, and build the policy with it.
# myapp/middleware.py
import secrets
import base64
def generate_nonce():
return base64.b64encode(secrets.token_bytes(16)).decode()
class ContentSecurityPolicyMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
request.csp_nonce = generate_nonce()
response = self.get_response(request)
policy = (
"default-src 'self'; "
f"script-src 'self' 'nonce-{request.csp_nonce}'; "
"style-src 'self'; "
"img-src 'self' data: https:; "
"font-src 'self'; "
"connect-src 'self'; "
"base-uri 'self'; "
"form-action 'self'; "
"frame-ancestors 'none'; "
"object-src 'none'"
)
response["Content-Security-Policy"] = policy
return response
Now expose the nonce to templates with a context processor:
# myapp/context_processors.py
def csp(request):
return {"csp_nonce": getattr(request, "csp_nonce", "")}
Register it:
# settings.py
TEMPLATES = [
{
"OPTIONS": {
"context_processors": [
# ...
"myapp.context_processors.csp",
],
},
},
]
Use it in a template:
<script nonce="{{ csp_nonce }}">
window.appConfig = {
csrfToken: "{{ csrf_token }}"
};
</script>
That script will run. A random inline script without the matching nonce won’t.
Report-Only in Django
When rolling out CSP, switch the header name:
response["Content-Security-Policy-Report-Only"] = policy
You can also keep this environment-based:
# settings.py
CSP_REPORT_ONLY = DEBUG is False
Then in middleware:
from django.conf import settings
header_name = (
"Content-Security-Policy-Report-Only"
if settings.CSP_REPORT_ONLY
else "Content-Security-Policy"
)
response[header_name] = policy
Django gotchas
A few things usually break first:
- inline
<script>blocks - inline event handlers like
onclick="..." - inline styles
- third-party widgets calling unexpected domains
- admin pages if you apply a strict site-wide policy without checking them
If you need to support a third-party script loader, you may need something like this:
policy = (
"default-src 'self' https://www.googletagmanager.com; "
f"script-src 'self' 'nonce-{request.csp_nonce}' 'strict-dynamic' https://www.googletagmanager.com; "
"style-src 'self' https://consent.cookiebot.com; "
"img-src 'self' data: https:; "
"connect-src 'self' https://*.google-analytics.com https://*.googletagmanager.com; "
"frame-src 'self' https://consentcdn.cookiebot.com; "
"frame-ancestors 'none'; "
"base-uri 'self'; "
"form-action 'self'; "
"object-src 'none'"
)
That pattern looks a lot more like real production traffic.
CSP in Flask
Flask is even simpler because response hooks are dead easy.
Basic Flask CSP
from flask import Flask
app = Flask(__name__)
@app.after_request
def add_csp(response):
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self'; "
"img-src 'self' data: https:; "
"font-src 'self'; "
"connect-src 'self'; "
"base-uri 'self'; "
"form-action 'self'; "
"frame-ancestors 'none'; "
"object-src 'none'"
)
return response
@app.route("/")
def index():
return "<h1>Hello</h1>"
That gets the header onto every response.
Flask with per-request nonces
Use g to store the nonce and a context processor to expose it to Jinja templates.
import base64
import secrets
from flask import Flask, g, render_template
app = Flask(__name__)
def generate_nonce():
return base64.b64encode(secrets.token_bytes(16)).decode()
@app.before_request
def set_csp_nonce():
g.csp_nonce = generate_nonce()
@app.context_processor
def inject_csp_nonce():
return {"csp_nonce": getattr(g, "csp_nonce", "")}
@app.after_request
def add_csp(response):
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
f"script-src 'self' 'nonce-{g.csp_nonce}'; "
"style-src 'self'; "
"img-src 'self' data: https:; "
"font-src 'self'; "
"connect-src 'self'; "
"base-uri 'self'; "
"form-action 'self'; "
"frame-ancestors 'none'; "
"object-src 'none'"
)
return response
@app.route("/")
def index():
return render_template("index.html")
Template:
<!doctype html>
<html>
<head>
<title>Flask CSP</title>
</head>
<body>
<h1>Hello</h1>
<script nonce="{{ csp_nonce }}">
window.appBooted = true;
</script>
</body>
</html>
Report-Only in Flask
Same idea as Django:
@app.after_request
def add_csp(response):
response.headers["Content-Security-Policy-Report-Only"] = (
"default-src 'self'; "
f"script-src 'self' 'nonce-{g.csp_nonce}'; "
"object-src 'none'; "
"base-uri 'self'; "
"frame-ancestors 'none'"
)
return response
strict-dynamic: useful, but know why you’re using it
You’ll see strict-dynamic in modern policies, including the headertest.com example.
Example:
script-src 'self' 'nonce-abc123' 'strict-dynamic' https://www.googletagmanager.com
This tells the browser to trust scripts loaded by already trusted nonce-bearing scripts. It’s useful for tag managers and script loaders. It can also make policies less brittle when a trusted bootstrap script loads more scripts.
But don’t cargo-cult it. If your app doesn’t need dynamic script loading, a nonce without strict-dynamic is easier to reason about.
Things I would avoid
A few bad habits show up constantly:
1. Using 'unsafe-inline' for scripts
script-src 'self' 'unsafe-inline'
That weakens CSP a lot. If you’re doing this for convenience, you’re mostly opting out.
Use nonces or hashes instead.
2. Using https: everywhere
script-src https:
That allows scripts from any HTTPS origin. Better than nothing, still far too broad for most apps.
3. Forgetting object-src 'none'
This one is cheap and should be in basically every policy.
4. Treating CSP as set-and-forget
Your policy will drift as the app changes. New analytics vendor, new CDN, new embedded widget, new frontend bundle path. CSP needs maintenance like any other security control.
Practical rollout plan
If I were adding CSP to an existing Django or Flask app, I’d do it like this:
- start with
Content-Security-Policy-Report-Only - add
object-src 'none',base-uri 'self', andframe-ancestors 'none'first - lock
default-src 'self' - explicitly allow images, fonts, styles, and API endpoints that actually exist
- replace inline scripts with nonce-based scripts
- move to enforcing mode
- keep the policy close to the app code, not buried in infrastructure nobody wants to touch
For framework-specific behavior and header details, the official docs are worth checking:
- Django docs: https://docs.djangoproject.com/
- Flask docs: https://flask.palletsprojects.com/
CSP is one of the few browser security controls that gives you immediate leverage against XSS. It’s also one of the easiest to accidentally weaken. Keep the policy tight, use nonces when you need inline script bootstrapping, and resist the temptation to “fix” violations by allowing the world.