Security Advanced

Content Security Policy for Django: Nonces, Trusted Types, and Killing XSS for Good

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.

DjangoZen Team Sep 05, 2026 18 min read 16 views

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.

How CSP works, in one paragraph

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.

Why domain allowlists disappoint

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.

A strict nonce-based policy in Django

# 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.

The trap: never cache a page with a nonce

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):
    ...

Rolling it out without breaking the site

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:

  1. Report-only for two weeks. Collect violations across real traffic, including admin pages and rarely used flows.
  2. Fix your own code. Inline handlers become addEventListener; inline styles become classes; template-generated JavaScript becomes json_script.
  3. Decide about third parties. Each one either supports nonces, works under strict-dynamic, or gets replaced. "Add unsafe-inline for the chat widget" is how policies die.
  4. Enforce on one route first — a low-traffic page — then widen.
  5. Keep report-only running alongside enforcement with a slightly stricter policy, as your early-warning channel for the next change.

Collecting reports without drowning

@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.

Removing inline code, concretely

<!-- 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.

Trusted Types: closing the DOM-based hole

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.

The settings that make CSP decorative

  • 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.
  • A nonce on a cached page — covered above, and the most common serious error.
  • Report-only forever. Reports that nobody acts on are a log file with extra steps. Set a date to enforce.

Verify it

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.