CSP turns cross-site scripting from a breach into a blocked console message. Ship a strict nonce-based policy, migrate without breaking the site, read the violation reports, and avoid the settings that make it decorative.
Django escapes template variables, so you are already ahead of most of the web. But one |safe, one third-party widget, one Markdown field rendered without sanitising, and an attacker executes JavaScript on your origin. A Content Security Policy is the second wall: even with injected script on the page, the browser refuses to run it.
You send a header listing where each type of resource may come from. The browser enforces it and refuses everything else — no exceptions, no override from page content. Because it is the browser doing the blocking, an attacker who controls part of your HTML still cannot execute code. That is the property nothing else gives you.
The obvious first policy is a list of trusted hosts:
Content-Security-Policy: script-src 'self' https://cdn.example.com https://analytics.example.com
This looks strict and usually is not. Large CDNs host libraries with JSONP endpoints or framework builds that can be coerced into evaluating attacker-supplied strings; allowlisting such a host effectively allows arbitrary script. Allowlists also rot — a vendor changes hostname, someone appends a domain to stop a complaint, and after a year the policy allows most of the internet.
The modern approach is per-response nonces: nothing is trusted by hostname; a script runs only if it carries the random value you generated for this response.
# pip install django-csp
MIDDLEWARE = [
"csp.middleware.CSPMiddleware",
...
]
CONTENT_SECURITY_POLICY = {
"DIRECTIVES": {
"default-src": ["'self'"],
"script-src": ["'self'", "'nonce'", "'strict-dynamic'"],
"style-src": ["'self'", "'nonce'"],
"img-src": ["'self'", "data:", "https:"],
"font-src": ["'self'"],
"connect-src": ["'self'"],
"frame-ancestors": ["'none'"],
"base-uri": ["'none'"],
"form-action": ["'self'"],
"object-src": ["'none'"],
"upgrade-insecure-requests": True,
},
}
<script nonce="{{ request.csp_nonce }}">
initDashboard();
</script>
<script nonce="{{ request.csp_nonce }}" src="{% static 'js/app.js' %}"></script>
Four directives do disproportionate work and are worth understanding:
'strict-dynamic' — a script that already passed the nonce check may load further scripts. This is what makes bundlers and tag managers workable without allowlisting their CDNs.object-src 'none' — kills Flash-era plugin vectors that still work in some engines. Free win, no downside.base-uri 'none' — stops an injected <base> tag redirecting every relative URL on the page, a bypass people forget exists.frame-ancestors 'none' — the modern replacement for X-Frame-Options; prevents clickjacking.A nonce must be unique per response. Cache the HTML and you serve the same nonce to everybody, which reduces it to a fixed password an attacker can read from the page — and worse, a cached page may carry a nonce that no longer matches the header.
So: any page containing {{ request.csp_nonce }} must be sent with Cache-Control: no-store, or must not be cached by your CDN or reverse proxy. If you want the page cached, move the inline script into an external file and drop the nonce from it entirely.
@cache_control(no_store=True)
def dashboard(request):
...
Enforcing a strict policy on a live site on day one will break something you did not know existed. Use report-only mode, which blocks nothing and reports everything:
CONTENT_SECURITY_POLICY_REPORT_ONLY = {
"DIRECTIVES": {
**STRICT_DIRECTIVES,
"report-uri": ["/csp-report/"],
},
}
The sequence that works:
addEventListener; inline styles become classes; template-generated JavaScript becomes json_script.strict-dynamic, or gets replaced. "Add unsafe-inline for the chat widget" is how policies die.@csrf_exempt
@require_POST
def csp_report(request):
try:
report = json.loads(request.body)["csp-report"]
except (ValueError, KeyError):
return HttpResponse(status=400)
directive = report.get("violated-directive", "")
blocked = report.get("blocked-uri", "")
if blocked.startswith(("chrome-extension:", "moz-extension:", "safari-extension:")):
return HttpResponse(status=204) # browser extensions, not you
key = hashlib.sha256(f"{directive}|{blocked}".encode()).hexdigest()[:16]
CSPViolation.objects.update_or_create(
fingerprint=key,
defaults={"directive": directive, "blocked_uri": blocked,
"document_uri": report.get("document-uri", "")[:500],
"last_seen": timezone.now()},
)
CSPViolation.objects.filter(fingerprint=key).update(count=F("count") + 1)
return HttpResponse(status=204)
Two survival tactics. Deduplicate by fingerprint — a single broken third-party script generates thousands of identical reports. And drop extension noise, which is the majority of real-world volume and tells you nothing about your site. Rate-limit the endpoint too; it is unauthenticated and public.
<!-- before -->
<button onclick="deleteItem({{ item.id }})">Delete</button>
<!-- after -->
<button class="js-delete" data-id="{{ item.id }}">Delete</button>
document.querySelectorAll(".js-delete").forEach(el =>
el.addEventListener("click", () => deleteItem(el.dataset.id))
);
Inline styles are the same story: style="display:none" becomes a class. Django's own admin needs some accommodation — django-csp ships nonce support for it, and it is worth applying rather than exempting the admin, which is the highest-value target on your site.
CSP stops injected <script> tags. It does not stop your own JavaScript writing attacker-controlled strings into innerHTML — DOM-based XSS, which is invisible to server-side escaping.
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types default dompurify
With this on, assigning a plain string to a dangerous sink throws. Values must pass through a named policy:
const policy = trustedTypes.createPolicy("dompurify", {
createHTML: (input) => DOMPurify.sanitize(input, { RETURN_TRUSTED_TYPE: false }),
});
el.innerHTML = policy.createHTML(userSuppliedMarkup);
Support is not universal, so treat it as defence in depth rather than a replacement for sanitising. Roll it out in report-only first — it surfaces every unsafe DOM write in your codebase, which is a genuinely useful inventory even before you enforce it.
unsafe-inline in script-src — the policy now permits exactly what XSS needs. (With nonces present, modern browsers ignore unsafe-inline anyway; keeping it only helps very old ones and confuses your audit.)unsafe-eval — required by some template libraries. Replace the library; it is worth more than the convenience.default-src * — a policy that permits everything is a header, not a defence.curl -sI https://example.com/ | grep -i content-security-policy
Then check the same header on a page with a nonce, reload twice, and confirm the nonce changes. If it does not, that page is being cached — fix that before anything else, because it silently disables the whole mechanism.
A strict CSP does not make XSS impossible. It makes a successful injection a blocked console message instead of a session-stealing script — and that is the difference between a bug report and an incident.