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.

KC Ramo · DjangoZen Team Sep 05, 2026 19 min read 115 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
from csp.constants import NONCE, SELF
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.

What happens to the nonce inside a request

With django-csp, request.csp_nonce is lazy. The middleware attaches a nonce generator to the request; the first time a template or view reads it, a fresh value is created from a cryptographically secure source, and on the way out the middleware substitutes 'nonce-<value>' into every directive where you wrote 'nonce'. If nothing read the nonce during the request, no nonce is added to the header. That design keeps the header honest — there is never a nonce in the header that does not appear in the page — but it has one consequence worth knowing: a page that renders no nonce'd tags gets a policy with 'strict-dynamic' and no nonce, which means no script runs at all. That is correct behaviour, and it is usually the first surprise when someone adds a page that extends a different base template.

Two further details matter in production. First, browsers deliberately hide the nonce attribute from the DOM: element.getAttribute("nonce") returns an empty string after parsing, while the element.nonce property still holds the value for your own scripts. This stops CSS attribute selectors and injected markup from exfiltrating the value, and it means you should not rely on reading the attribute when debugging. Use the Network panel to compare the response header with the raw HTML instead. Second, the nonce must be generated per response, not per process or per session. A value stored on a module-level variable or in the session is a password that never rotates; if you ever write your own middleware, generate it inside the request cycle with secrets.token_urlsafe() and nowhere else.

Adjusting the policy for one view

Most sites need a handful of exceptions: a page that embeds a video provider, a report page that loads a charting library from a vendor, an OAuth callback that posts a form to an identity provider. Resist the urge to widen the global policy for them. django-csp 4.x provides decorators that modify the policy for a single view, taking the same directive dictionary shape as the settings:

from csp.decorators import csp_update, csp_replace

@csp_update({"frame-src": ["https://player.example.com"]})
def product_video(request, slug):
    ...

@csp_replace({"form-action": ["'self'", "https://id.example.com"]})
def start_login(request):
    ...

csp_update appends sources to the named directives; csp_replace swaps the directive value entirely. Both accept REPORT_ONLY=True to target the report-only policy instead of the enforced one. There is also csp_exempt; use it only for responses that are not documents, and grep for it in code review, because an exempt HTML view is an unprotected page on your origin. Keeping exceptions next to the view that needs them has a second benefit: when the view is deleted, the exception goes with it, instead of lingering in a global allowlist for years.

Nonces, hashes and allowlists: choosing per script

Nonces are not the only way to authorise a script without unsafe-inline. CSP also accepts a hash of an inline script's exact contents, such as 'sha256-...'. The browser hashes the text between <script> and </script> and runs it only if the digest matches one in the policy. Hashes have one big advantage over nonces: they do not change per response, so a page authorised by hashes can be cached by a CDN without weakening anything.

import base64
import hashlib

def csp_hash(script_body: str) -> str:
    digest = hashlib.sha256(script_body.encode("utf-8")).digest()
    return "'sha256-" + base64.b64encode(digest).decode("ascii") + "'"

# Must be byte-identical to what sits between the tags, whitespace included.
print(csp_hash("initDashboard();"))

The catch is that the hash covers every byte, including indentation and the newline your editor adds. Reformat the template and the script silently stops running. Hashes also cannot cover anything that contains per-request values, because the content changes. In practice they suit small, stable bootstrapping snippets on cached pages, and they should be generated by a build step or a test rather than pasted by hand.

MechanismWorks with CDN cachingHandles dynamic inline contentMaintenance costBypass risk
Nonce + 'strict-dynamic'No (page must be no-store or uncached)YesLow: add the attribute in templatesLow, provided nonces are unique and unpredictable
HashYesNoMedium: hashes break on any editLow
Host allowlistYesNo (external files only)High: rots as vendors changeHigh on large shared CDNs
'self' only, no inlineYesNoLow once inline code is goneModerate: any uploaded or JSONP-style endpoint on your origin becomes a script source

A common and sensible split: authenticated, per-user pages (which you should not be caching publicly anyway) use nonces; public marketing pages that must be cached at the edge have no inline script at all and load one external bundle from 'self', optionally with a hash for a tiny bootstrap snippet. Note that when 'strict-dynamic' is present, supporting browsers ignore 'self' and host sources in script-src; they remain in the policy only as a fallback for older engines, which is why the policy above still lists 'self'.

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.

Two report formats, one endpoint

The view above parses the classic format sent for report-uri: a single JSON object under a csp-report key, with Content-Type: application/csp-report. Browsers are moving to the Reporting API, which you opt into with a Reporting-Endpoints response header and the report-to directive. Its payload is different: a JSON array of reports, sent as application/reports+json, each with type set to csp-violation and camelCase field names in a body object. Sending both directives is the pragmatic choice today — browsers that understand report-to use it and ignore report-uri, older ones fall back — so the endpoint should normalise both shapes:

def normalise_reports(request):
    """Yield (directive, blocked, document, disposition) from either format."""
    payload = json.loads(request.body)

    if isinstance(payload, dict) and "csp-report" in payload:          # report-uri
        r = payload["csp-report"]
        yield (
            r.get("effective-directive") or r.get("violated-directive", ""),
            r.get("blocked-uri", ""),
            r.get("document-uri", ""),
            r.get("disposition", "enforce"),
        )
        return

    if isinstance(payload, list):                                       # report-to
        for item in payload[:50]:                                       # cap batch size
            if item.get("type") != "csp-violation":
                continue
            body = item.get("body", {})
            yield (
                body.get("effectiveDirective", ""),
                body.get("blockedURL", ""),
                body.get("documentURL", ""),
                body.get("disposition", "enforce"),
            )

Record the disposition field. It tells you whether a report came from the enforced policy (something was actually blocked for a real user) or the report-only policy (something would be blocked). Alerting should fire on enforced violations from your own origin; report-only violations belong in a weekly review.

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.

Passing server data to JavaScript without inline code

The last category of inline script in most Django projects is configuration: a block that sets window.USER_ID, an API base URL, or a list of options rendered with template tags. Besides needing a nonce, interpolating template variables into JavaScript is itself a classic XSS source, because HTML escaping is the wrong escaping for a JavaScript string context. Django's json_script filter solves both problems at once. It emits a <script type="application/json"> element, which the browser never executes and CSP therefore does not govern, with the characters that could break out of the element escaped:

{# view context: {"dashboard_config": {"userId": 42, "apiBase": "/api/v1/", "features": ["export"]}} #}
{{ dashboard_config|json_script:"dashboard-config" }}

<script nonce="{{ request.csp_nonce }}" src="{% static 'js/dashboard.js' %}"></script>
// static/js/dashboard.js
const config = JSON.parse(
  document.getElementById("dashboard-config").textContent
);
initDashboard(config);

Reading through textContent and JSON.parse means the data is only ever treated as data. Contrast it with the pattern it replaces, var name = "{{ user.name }}";, where a name containing a newline or a crafted sequence can break the string literal, or {{ data|safe }} inside a script block, which is a direct injection point. If a code review finds a template variable inside a <script> element that is not json_script, treat it as a bug even if the page is already under CSP.

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.

A default policy as a migration bridge

Rewriting every innerHTML assignment before you can enforce Trusted Types is unrealistic in a large front end. The default policy — named in the header above — is the bridge. When enforcement is on and code assigns a plain string to a sink, the browser calls the default policy instead of throwing immediately. It can sanitise, log, or reject:

if (window.trustedTypes && trustedTypes.createPolicy) {
  trustedTypes.createPolicy("default", {
    createHTML(value, typeName, sink) {
      reportLegacySink(sink);                 // send to your logging, rate-limited
      return DOMPurify.sanitize(value, { RETURN_TRUSTED_TYPE: false });
    },
    createScriptURL(value) {
      const url = new URL(value, document.baseURI);
      if (url.origin === location.origin) {
        return url.href;
      }
      throw new TypeError("Script URL not allowed: " + url.origin);
    },
    createScript() {
      throw new TypeError("String-to-code conversion is disabled");
    },
  });
}

This file must run before any other script touches a sink, so load it first in the page with its own nonce. The default policy turns enforcement from a flag day into a gradual migration: legacy code keeps working through sanitisation, every call is logged with the sink name, and you convert call sites to the named dompurify policy — or better, to textContent where no HTML was needed — until the logs go quiet. At that point, remove default from the trusted-types directive and any remaining plain-string assignment becomes a hard error.

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.

Testing the policy like any other feature

A CSP is configuration that silently degrades: someone adds a source to fix a bug, a new base template forgets the nonce, a caching decorator lands on the wrong view. Tests catch all three before users do. Start with server-side assertions in the normal Django test suite:

import re

from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse

NONCE_RE = re.compile(r"'nonce-([^']+)'")

def directive(header: str, name: str) -> str:
    for part in header.split(";"):
        part = part.strip()
        if part.startswith(name + " "):
            return part
    return ""

class ContentSecurityPolicyTests(TestCase):
    url_name = "dashboard"

    def setUp(self):
        user = get_user_model().objects.create_user("deploy", password="unused-in-tests")
        self.client.force_login(user)

    def test_script_src_is_strict(self):
        header = self.client.get(reverse(self.url_name)).headers["Content-Security-Policy"]
        script_src = directive(header, "script-src")
        self.assertIn("'strict-dynamic'", script_src)
        self.assertNotIn("'unsafe-inline'", script_src)
        self.assertNotIn("'unsafe-eval'", script_src)
        self.assertIn("object-src 'none'", header)
        self.assertIn("base-uri 'none'", header)

    def test_nonce_matches_markup_and_rotates(self):
        seen = set()
        for _ in range(2):
            response = self.client.get(reverse(self.url_name))
            match = NONCE_RE.search(response.headers["Content-Security-Policy"])
            self.assertIsNotNone(match, "no nonce in header")
            self.assertContains(response, f'nonce="{match.group(1)}"')
            seen.add(match.group(1))
        self.assertEqual(len(seen), 2, "nonce did not change between responses")

    def test_nonce_page_is_not_cacheable(self):
        response = self.client.get(reverse(self.url_name))
        self.assertIn("no-store", response.headers.get("Cache-Control", ""))

Parametrise the class over every URL name that renders a nonce, or generate the list from your URL configuration, so new pages are covered by default. Server-side tests cannot tell you whether the browser actually blocked something; only a browser can. If you already run end-to-end tests with Playwright, collect violations through the securitypolicyviolation DOM event and fail the test when any appear. Scripts injected through Playwright's init-script mechanism are not subject to the page's policy, so the listener works on a strict page — but do not set the browser context's bypass_csp option, which would disable the very thing you are testing:

LISTENER = """
window.__cspViolations = [];
document.addEventListener("securitypolicyviolation", (e) => {
  window.__cspViolations.push(`${e.effectiveDirective} ${e.blockedURI}`);
});
"""

def test_dashboard_has_no_csp_violations(page, live_server_url):
    page.add_init_script(LISTENER)
    page.goto(f"{live_server_url}/dashboard/")
    page.get_by_role("button", name="Export").click()
    violations = page.evaluate("window.__cspViolations")
    assert violations == [], violations

Run the browser test against the enforced policy, not the report-only one, so that a violation also shows up as a broken interaction and the failure is unambiguous.

Troubleshooting common failures

Most CSP problems produce one of a small number of console messages. The browser tells you which directive blocked what; the work is mapping that to a cause in your Django project.

SymptomLikely causeFix
Every script on one page is blocked; other pages fineThe page extends a base template that never reads request.csp_nonce, so the header has 'strict-dynamic' but no nonceAdd nonces to the scripts in that base template
Scripts blocked intermittently, nonce in HTML differs from headerFull-page, fragment or CDN caching of nonce'd HTMLRemove the cache for that page or fragment; send no-store
Scripts blocked even though the nonce matches Django's headerA second policy from nginx, a load balancer or a meta tag; all policies are enforcedRemove the other policy; check with curl -sI for duplicate headers
A vendor's secondary scripts are blocked under 'strict-dynamic'The vendor uses document.write or parser-inserted scripts, which are not trustedUpgrade the vendor snippet, or replace the vendor
Buttons do nothing; script-src-attr violationsInline onclick and similar handlersMove to addEventListener with data- attributes
Violations with blocked URI evalA library using eval or new Function, including some template engines and older polyfillsConfigure the library's precompiled mode or replace it; do not add 'unsafe-eval'
Styles missing; style-src-attr violationsInline style attributes from templates or widgetsMove to classes; set properties via element.style in JavaScript if needed
Fetch calls fail with connect-src violationsAPI, websocket or vendor host not in connect-srcAdd the specific host, per view if possible; include wss: URLs for websockets on other hosts
Reports mention pages you never enforcedReport-only and enforced policies both sending to the same endpointStore and filter by disposition

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.