skip to content

A Django 6.x marketing page uses a CSP nonce and is wrapped in cache_page; after the first visit its inline scripts are blocked. Why, and how do you fix it?

level: seniorimportance: should knowfreq 22%

answer

  1. what a cache hit skips
  2. the lazy nonce is never read
  3. placeholder dropped from header
  4. per-site cache fails differently

basics

~20 s

On a cache_page hit the template is not rendered, so the lazy nonce is never read and the middleware drops it from the header, while the cached HTML carries the first visitor's nonce. Don't full-page cache nonce pages.

solid answer

~40 s

`cache_page` stores the rendered body before `ContentSecurityPolicyMiddleware` adds its header. On a hit, the view and template do not run, so nobody reads the request's `LazyNonce`; `build_policy()` then removes the `CSP.NONCE` placeholder, and the header allows no nonce while the cached `<script nonce="...">` carries an old value, so the browser blocks it. The per-site cache middleware in its documented position fails the other way: it stores the header too, so every visitor gets the same nonce, which works but defeats the nonce. The fixes are to stop full-page caching pages that use `csp_nonce`, move the inline code into static files allowed by `'self'`, or cache only fragments that contain no nonce.

code

python · 9 lines
python
from django.shortcuts import render
from django.views.decorators.cache import cache_page


# Broken: on a cache hit the template does not render, the request's nonce
# is never read, and the header carries no nonce for the cached inline script.
@cache_page(60 * 15)
def spring_launch(request):
    return render(request, "marketing/spring_launch.html")

go deeper

for a junior

Recall that a CSP nonce must be new for every response, so a page that uses one cannot simply be cached whole.

for a middle

Explain the lazy nonce and why an unread nonce is dropped from the header on a cache_page hit.

for a senior

Diagnose the two failure modes (blocked with cache_page, reused with per-site caching) and choose a fix: static scripts, data caching, nonce-free fragments.

for a principal

Weigh page-level caching against nonce-based CSP for high-traffic pages and set a rule for which templates may use nonces.

## The symptom A campaign landing page gets heavy traffic, so someone adds `@cache_page(60 * 15)`. The page's inline script carries `nonce="{{ csp_nonce }}"` and the policy is `script-src 'self' <nonce>`. The first visitor sees a working page. Every later visitor, for fifteen minutes, gets a page whose inline script is blocked, and the browser console reports a CSP violation. ## Why cache_page breaks the nonce Three facts from Django's source combine: 1. **The nonce is lazy.** `ContentSecurityPolicyMiddleware` attaches a `LazyNonce` to each request. It is generated only when something reads it, normally the template rendering `csp_nonce`. 2. **The header drops an unread nonce.** When building the header, `build_policy()` replaces `CSP.NONCE` with `'nonce-<value>'` only if the nonce was generated; otherwise it removes the placeholder, and removes the directive entirely if nothing is left. 3. **cache_page works inside the view layer.** `cache_page` wraps the view with the cache middleware as a decorator. On a miss it stores the response after rendering, before the site-wide CSP middleware adds its header. On a hit it returns the stored response without calling the view. So on a hit, the body contains the first visitor's nonce, nothing reads the new request's nonce, and the fresh header carries no nonce at all. The inline script matches no allowed source and is blocked. ## The per-site cache fails differently With the per-site cache middleware pair in its documented position (the update middleware first in `MIDDLEWARE`, the fetch middleware last), the cache stores the response after the CSP middleware has added its header. On a hit, the stored header and body are returned together, and the CSP middleware leaves the existing header alone. The page **works**, but every visitor receives the same nonce until the entry expires. | Caching layer | What a hit serves | Visible effect | Security effect | |---|---|---|---| | `cache_page` on the view | old body, fresh header without a nonce | inline scripts blocked | none, but the page is broken | | per-site cache middleware | old body, old header | page works | nonce reused across users, protection weakened | | template fragment cache around a nonce tag | old nonce inside a fresh page | that tag blocked | none, but that part is broken | | no full-page cache | fresh body and header | works | intended protection | Django's own documentation on nonces warns about exactly this: serving cached responses with previously generated nonces can make the nonce reused across users and requests, which defeats its purpose even when the page appears to work. ## Fixes, in order of preference 1. **Move inline code out.** Put the script in a static file and load it with `<script src="{% static ... %}">`; `script-src 'self'` allows it without a nonce, and the page can be cached freely. 2. **Do not full-page cache nonce pages.** Cache the expensive data instead: querysets, API results, or template fragments that contain no nonce. 3. **Keep nonce tags outside cached fragments.** A `{% cache %}` block that contains `nonce="{{ csp_nonce }}"` freezes the nonce just like a page cache. 4. **If a whole-page cache is unavoidable**, it must inject a fresh nonce into both header and body on every hit, which Django does not provide. ## Why the nonce cannot simply be cached with the page It is tempting to ask why Django does not store the nonce with the cached page and reuse it. The answer is the nonce's purpose: it is valuable only because an attacker who injects markup cannot predict it. A nonce served to many visitors over fifteen minutes is observable by any of them, including the attacker, who can then write a matching `nonce` attribute into injected markup. Per-response uniqueness is the security property, and any cache that freezes the rendered HTML removes it. ## How to confirm the diagnosis - Compare the `nonce` attribute in the HTML with the `Content-Security-Policy` header across two requests: with `cache_page`, the header lacks a nonce on the second request; with per-site caching, both requests show the same value. - Remove the decorator in a staging environment and check the header now carries a fresh nonce each time. - Look for `Cache-Control` and `Expires` headers that the cache middleware adds, which show the response came through the cache layer.

  • Would switching to the per-site cache middleware fix the blocked script?
    It would make the page work, because the cached header and body now match, but it serves one nonce to every visitor until the entry expires. That is nonce reuse, which Django's documentation warns weakens the protection. It trades a visible bug for an invisible one.
  • Why does the fresh header on a cache hit contain no nonce instead of a new one?
    The middleware only inserts a nonce that was generated during the request, and the lazy nonce is generated when first read. On a cache hit no template renders, so nothing reads it; `build_policy()` then removes the `CSP.NONCE` placeholder. Even a new nonce would not help, since the cached body carries the old value.

saying these in an interview costs you the question

  • The CSP middleware rewrites the nonce inside cached HTML on each request.
  • A cached page with a matching reused nonce is still fully protected.
  • Raising the cache timeout keeps the nonce valid for longer.
  • Nonces and full-page caching combine safely as long as the cache is per URL.
  • Wrapping the nonce tag in a {% cache %} fragment avoids the problem.