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-src uses a nonce and strict-dynamic
  • style-src still has 'unsafe-inline', which is common but not ideal
  • connect-src allows analytics endpoints and WebSocket traffic
  • frame-ancestors 'none' blocks clickjacking
  • object-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:

  1. start with Content-Security-Policy-Report-Only
  2. add object-src 'none', base-uri 'self', and frame-ancestors 'none' first
  3. lock default-src 'self'
  4. explicitly allow images, fonts, styles, and API endpoints that actually exist
  5. replace inline scripts with nonce-based scripts
  6. move to enforcing mode
  7. 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:

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.