Configure a Content Security Policy for Django

Add a Content Security Policy to Django: Django 6 built-in CSP settings or django-csp, nonces for inline scripts, report-only rollout, collecting violation reports, and handling third-party scripts.

Problem statement

Django escapes template output by default, but cross-site scripting still happens: a |safe filter in the wrong place, a vulnerable JavaScript dependency, user HTML rendered by a rich text field, or an injected third-party script. A Content Security Policy (CSP) is a response header that tells the browser which sources of scripts, styles, images, and connections the page may use. Injected script from anywhere else is refused, even if it reaches the page.

The difficulty is rollout: a strict policy applied at once breaks inline scripts, analytics, fonts, and embeds. This guide adds a policy with Django's built-in support (Django 6.0 and newer) or the django-csp package, uses nonces for the inline scripts you keep, and rolls out in report-only mode first.

CSP rollout pathReport-onlyobserve violationsReportscollect and reviewAdjust policynonces, sourcesEnforceblock the restRun the policy in report-only mode first, so real violations show up before anything is blocked.
  1. Start with a strict base: default-src 'self', object-src 'none', base-uri 'self', frame-ancestors 'self'.
  2. Enable it in report-only mode (SECURE_CSP_REPORT_ONLY in Django 6.0+, or django-csp's report-only setting).
  3. Collect violation reports through a reporting endpoint for one or two weeks.
  4. Move inline scripts to static files, or give them a per-request nonce.
  5. Add only the third-party sources you actually need to the relevant directives.
  6. Switch to the enforcing header, keep reports on, and set the policy in one place only.

Step-by-step solution

1. Draft a base policy

A starting point for a typical Django site that serves its own static files:

default-src 'self';
script-src 'self' 'nonce-...';
style-src 'self';
img-src 'self' data:;
font-src 'self';
connect-src 'self';
object-src 'none';
base-uri 'self';
frame-ancestors 'self';
form-action 'self';
  • object-src 'none' disables plugins.
  • base-uri 'self' stops injected <base> tags from redirecting relative URLs.
  • frame-ancestors controls who may embed your pages (the modern replacement for X-Frame-Options).
  • Add CDN, analytics, or media hosts only to the directives that need them (for example your media bucket in img-src).

2a. Django 6.0 and newer: built-in CSP

# settings.py
from django.utils.csp import CSP

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.middleware.csp.ContentSecurityPolicyMiddleware",
    # ...
]

SECURE_CSP_REPORT_ONLY = {
    "default-src": [CSP.SELF],
    "script-src": [CSP.SELF, CSP.NONCE],
    "style-src": [CSP.SELF],
    "img-src": [CSP.SELF, "data:", "https://media.example.com"],
    "object-src": [CSP.NONE],
    "base-uri": [CSP.SELF],
    "frame-ancestors": [CSP.SELF],
    "form-action": [CSP.SELF],
    "report-uri": ["/csp-report/"],
}

TEMPLATES[0]["OPTIONS"]["context_processors"] += ["django.template.context_processors.csp"]

SECURE_CSP_REPORT_ONLY sends Content-Security-Policy-Report-Only; SECURE_CSP sends the enforcing header. CSP.NONCE inserts a per-request nonce, and the csp context processor makes it available in templates as csp_nonce. Per-view adjustments are possible with the decorators in django.views.decorators.csp. Check the Django documentation for your exact version, since the feature is new.

2b. Older Django: django-csp

pip install django-csp
MIDDLEWARE += ["csp.middleware.CSPMiddleware"]

from csp.constants import NONCE, NONE, SELF

CONTENT_SECURITY_POLICY_REPORT_ONLY = {
    "DIRECTIVES": {
        "default-src": [SELF],
        "script-src": [SELF, NONCE],
        "img-src": [SELF, "data:", "https://media.example.com"],
        "object-src": [NONE],
        "base-uri": [SELF],
        "frame-ancestors": [SELF],
        "report-uri": "/csp-report/",
    }
}

django-csp 4.x uses these dictionary settings; older 3.x releases used separate CSP_* settings. The nonce is available as request.csp_nonce.

3. Use nonces for inline scripts you keep

<script nonce="{{ csp_nonce }}">
  document.documentElement.classList.add("js");
</script>

With django-csp, use nonce="{{ request.csp_nonce }}". Better still, remove inline scripts:

  • Move code into static .js files (allowed by 'self').
  • Pass data with {{ value|json_script:"id" }}, which renders a <script type="application/json"> element that is data, not code, and read it with JSON.parse(document.getElementById("id").textContent).
  • Replace inline event handlers (onclick="...") with addEventListener in a static file; nonces do not apply to them.

Responses with nonces differ per request, so do not cache full HTML pages in a shared cache unless the nonce is handled accordingly.

4. Collect reports

A minimal endpoint that logs violations:

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("security.csp")


@csrf_exempt
@require_POST
def csp_report(request):
    if len(request.body) < 20_000:
        try:
            report = json.loads(request.body).get("csp-report", {})
            logger.warning(
                "csp violation directive=%s blocked=%s page=%s",
                report.get("violated-directive"), report.get("blocked-uri"), report.get("document-uri"),
            )
        except (ValueError, AttributeError):
            pass
    return HttpResponse(status=204)

Browsers send reports as application/csp-report JSON to the report-uri. Reports are noisy (browser extensions inject scripts too); look for patterns across many users rather than individual reports. Hosted reporting services and error trackers such as Sentry can also receive CSP reports. Rate limit the endpoint at the proxy.

one recurring report, three answersWhat is this recurring violation?your inline scriptStatic file or nonceavoid unsafe-inlinea needed third partyAdd its exact hostto one directiveextension or injectionLeave it blockedthe policy is workingLook for patterns across many reports; single reports are mostly browser noisedjango-deployment.com
Adding a host is a deliberate trust decision, not a way to silence reports.

5. Adjust, then enforce

from report-only to enforced1Base policy in report-only modenothing is blocked yet2Collect reports for one to two weeksreal pages, real browsers3Fix sources: nonces, static files, hostsavoid unsafe-inline4Switch to the enforcing headerkeep reporting on5Trial stricter policy in report-onlyboth headers at onceReport-only first means violations surface before anything breaksdjango-deployment.com
Sending both headers lets you enforce a known-good policy while testing the next one.

For each recurring violation, decide: remove the resource, host it yourself, add its exact host to the right directive, or give the inline code a nonce. Avoid 'unsafe-inline' in script-src: it disables most of the protection. For inline styles from third-party widgets, 'unsafe-inline' in style-src is a common, lower-risk compromise.

Once reports are quiet, switch the setting from report-only to enforcing (SECURE_CSP, or CONTENT_SECURITY_POLICY). You can send both headers at once, enforcing a known-good policy while testing a stricter one in report-only mode.

6. Set the header in one place

If Nginx also adds a Content-Security-Policy header, browsers enforce both, and the effective policy is their intersection, which blocks things neither policy alone would. Generate CSP only in Django (where nonces live) and remove any CSP add_header from Nginx. Large policies also add to response header size.

Verification check:

curl -sI https://example.com/ | grep -i "content-security-policy"

Load key pages, the admin, forms, and payment flows with the browser console open: no CSP errors in enforcing mode, and nonce values that change on every reload.

Explanation

A CSP turns the browser into an enforcement point: every script, style, image, connection, and frame is checked against the policy before loading. Injected markup that tries to load or run code from an unapproved source is blocked, which limits the damage of XSS bugs you have not found yet. Nonces let specific inline scripts run by marking them with a random value that an attacker cannot predict. Report-only mode exists because real pages load more than anyone remembers, and discovering that from violation reports is safer than from broken pages.

Edge cases / notes

Django admin

Test the admin under the enforcing policy, including pages with widgets (date pickers, autocomplete, inline forms). Third-party admin themes and packages may use inline scripts or styles that need exceptions.

Analytics and tag managers

Tag managers load arbitrary scripts by design, which conflicts with a strict script-src. Either list the specific hosts they need, or use 'strict-dynamic' with nonces so scripts loaded by a trusted (nonced) script are allowed.

When to automate this

Add a test asserting the CSP header is present with key directives, and keep a report-only candidate policy running in production so tightening the policy is always based on current data.

Next steps

FAQ

Does Django have built-in Content Security Policy support?

Yes, from Django 6.0, with ContentSecurityPolicyMiddleware, the SECURE_CSP and SECURE_CSP_REPORT_ONLY settings, and nonce support. Older versions use the django-csp package.

How do I allow an inline script with CSP in Django?

Add a nonce: include the nonce source in script-src and render nonce="{{ csp_nonce }}" (built-in) or nonce="{{ request.csp_nonce }}" (django-csp) on the script tag.

Should I start CSP in report-only mode?

Yes. Report-only shows what would be blocked without breaking pages. Enforce after reviewing reports.

Can I set CSP in both Nginx and Django?

Avoid it. Browsers enforce every policy header, so two policies combine restrictively. Set CSP in one place, preferably Django.

Learning path · step 5 of 15

Hardening a Django deployment

Keep going