Django's Built-In CSP: What 6.0 Shipped and 6.1 Fixed
The admin login page came up with no styling and no JavaScript. The console was a wall of Refused to load the script errors, every one of them pointing at something under /static/admin/js/. An hour earlier I had added nine lines to settings.py on a Django 6.0 project to turn on a nonce-based Content Security Policy. The policy was fine. The admin templates were the problem, and that turned out to be a known bug rather than my mistake.
If you are adopting the CSP support Django now ships in core, that is the shape of the first afternoon. The settings are genuinely simple. The surprises are all in what a strict policy does to code you did not write — the admin, your form widgets, your cache layer.
What 6.0 actually moved into core
Before 6.0 this was third-party territory. Django 6.0’s release notes list Content Security Policy support as a headline feature, and the pieces are small enough to summarise in full:
django.middleware.csp.ContentSecurityPolicyMiddleware, which builds the header on each response.- Two settings,
SECURE_CSPandSECURE_CSP_REPORT_ONLY, both of which take plain Python dictionaries. - A
CSPenum indjango.utils.cspholding the quoted keywords, so you never hand-write'self'. - The
django.template.context_processors.cspcontext processor, which exposes the per-request nonce to templates. csp_overrideandcsp_report_only_overridedecorators indjango.views.decorators.cspfor per-view policies, documented in the CSP reference.
The config is a dictionary of directive names to lists of sources:
# settings.py
from django.utils.csp import CSP
MIDDLEWARE = [
# ...
"django.middleware.csp.ContentSecurityPolicyMiddleware",
]
SECURE_CSP = {
"default-src": [CSP.SELF],
"script-src": [CSP.SELF, CSP.NONCE],
"img-src": [CSP.SELF, "https:"],
}
That produces default-src 'self'; script-src 'self' 'nonce-SECRET'; img-src 'self' https:. The enum covers the keywords you actually reach for — NONE, SELF, STRICT_DYNAMIC, UNSAFE_INLINE, UNSAFE_EVAL, UNSAFE_HASHES, WASM_UNSAFE_EVAL, REPORT_SAMPLE and NONCE — and the reference page is explicit that the two settings can be used independently or together, which is the detail the rollout below depends on.
One thing the dictionary shape does not give you is merging. More on that further down, because it bit me.
The nonce is the whole game
A policy of default-src 'self' is easy and nearly useless on a real app, because real apps have inline <script> blocks — analytics snippets, a CSRF-aware fetch helper, the one window.__INITIAL_STATE__ dump someone added in 2023. The moment you drop 'unsafe-inline', those stop running.
Nonces are the way out. You put CSP.NONCE in script-src, enable the context processor, and tag your inline elements:
<script nonce="{{ csp_nonce }}">
document.querySelector("#filters").hidden = false;
</script>
Two mechanics here are worth knowing before you debug anything. First, generation is lazy: the CSP reference says a nonce is created only if {{ csp_nonce }} is actually accessed during rendering. Second, the inverse failure is silent-ish — if a template emits {{ csp_nonce }} but your policy has no NONCE in the matching directive, the browser just blocks the element.
If you want the stricter setup, CSP.STRICT_DYNAMIC lets a nonced script load further scripts without you allowlisting every CDN. The trade-off is that it changes how the rest of the directive is read: MDN notes that with 'strict-dynamic' present, any allowlist or source expressions such as 'self' or 'unsafe-inline' will be ignored. People add 'strict-dynamic' to an existing allowlist, see nothing break in their own code, and conclude the allowlist is still doing work. It is not.
The admin was the first thing to break
This is the part I lost an afternoon to. With a nonce-based policy on Django 6.0, the admin’s own <script> and <link> tags carry no nonce, so the browser blocks them and the admin renders as unstyled HTML. It is ticket #36825, “CSP nonces are not applied in the Django admin”, filed against 6.0, now closed as fixed — the fix extended the admin, error page and registration templates so the nonce is included when one is available.
That fix is in 6.1, and it is buried in the minor features. The 6.1 release notes state that CSP nonce attributes are now added on <script>, <style> and <link> elements in admin templates and all built-in templates when the csp() context processor is configured. So the practical advice is blunt: if you are putting a nonce policy in front of the Django admin, do the 6.1 upgrade first. On 6.0 your options are forking admin templates or weakening the policy for admin URLs, and both are worse than an upgrade you were going to do anyway.
Django 6.1, released on 5 August 2026, adds two more things that only matter once you are actually using nonces.
The first is a template tag for external assets. {{ csp_nonce }} works for inline blocks, but a <script src="..."> needs the attribute rendered on the element, and the 6.1 CSP reference documents csp_nonce_attr for exactly that:
<script src="{% static 'app/filters.js' %}" {% csp_nonce_attr %}></script>
<link rel="stylesheet" href="{% static 'app/filters.css' %}" {% csp_nonce_attr %}>
It also takes a Media object, which is the fix for form widget assets you do not control the markup of:
{% csp_nonce_attr form.media %}
The second is a system check that catches the exact misconfiguration described above. From the checks reference, security.W027 fires when “Your Content Security Policy includes CSP.NONCE and ContentSecurityPolicyMiddleware is enabled, but django.template.context_processors.csp is not configured.” There is also security.E026, which errors if a CSP setting is not a dictionary. Run manage.py check --deploy after you edit the policy; it is cheaper than reading a console.
Three things that cost me time
Per-view overrides replace, they do not merge. This reads like a convenience decorator and behaves like a full swap. The reference is clear that overrides fully replace the base policy, and an empty mapping removes the header entirely:
from django.utils.csp import CSP
from django.views.decorators.csp import csp_override
# This view now has NO default-src, NO script-src. Only img-src.
@csp_override({"img-src": [CSP.SELF, "data:"]})
def chart_png(request): ...
I wrote something very close to that to allow data: images on one endpoint and quietly removed every other directive from it. Build overrides from your base dict instead of writing them fresh.
A cached page serves a dead nonce. Whole-response caching and nonces do not mix: the 6.1 reference warns that caching full responses can reuse a nonce across users and requests, and notes it will look like it works, because the cached HTML and the cached header still match each other. It defeats the point of the nonce, which is that it is unpredictable per request. If a view must be cached at the response level, get the inline script out of it rather than getting clever.
Nobody is collecting your reports. report-uri in the policy makes browsers send violation reports somewhere, and the CSP reference states plainly that Django does not receive, store or process them — you provide the endpoint or a third-party service. A report-only rollout with no endpoint wired up is a policy nobody is reading.
If you already run django-csp
For years the answer here was Mozilla’s django-csp. Its PyPI page lists version 4.0 from 2 April 2025, maintained by the Mozilla MEAO team, with framework classifiers covering Django 4.2 through 5.2 and no mention of the core support.
I would not rip it out mid-sprint. One detail makes coexistence survivable: the core middleware builds its headers unless another layer has already set them, per the CSP reference. So the package keeps winning while it is installed, and you can move at your own pace. On anything new on 6.x I use core and skip the dependency.
The rollout order that worked
- Upgrade to 6.1 first if the Django admin is in scope. Everything else is downstream of that.
- Add the middleware and
SECURE_CSP_REPORT_ONLYwithdefault-src 'self'and areport-uriyou have actually built. Enforce nothing yet. - Read the reports for a full week, including a weekend. Admin-heavy traffic and marketing-script traffic do not show up on the same days.
- Add the context processor, then put nonces on your inline blocks and
{% csp_nonce_attr %}on your external ones. Runcheck --deployand clearsecurity.W027. - Promote the policy from
SECURE_CSP_REPORT_ONLYtoSECURE_CSP, and keep report-only alive for the next change you want to trial. Both settings running at once is the supported way to test a tightening without shipping it. - Only then consider
strict-dynamic, and when you do, delete the allowlist it has just made inert instead of leaving it there to mislead the next person.
The thing worth internalising is that CSP in core did not make CSP easy. It removed a dependency and gave you a dictionary. The work is still the inventory of every inline script and third-party asset your app actually loads, and the only honest way to get that list is a week of report-only traffic.
Sources
- Django 6.0 release notes — Content Security Policy support
- Django 6.0 CSP reference: settings, decorators, nonce and caching
- Django how-to: use Django’s Content Security Policy
- Django ticket #36825 — CSP nonces are not applied in the Django admin
- Django 6.1 release notes
- Django 6.1 CSP reference — csp_nonce_attr
- Django system check reference — security.W027 and security.E026
- Django 6.1 released — weblog announcement
- MDN — CSP script-src directive
- django-csp on PyPI