All posts

How to add a fresh CSP nonce to a static site with nginx

CentralCSP Team ·

Last update:

A strict Content Security Policy (CSP) leans on a nonce: a random value that changes on every response, sent in the Content-Security-Policy header and repeated on each trusted <script> tag. A dynamic app generates that value while it renders the page. A static site has nothing rendering the page, and nginx has no built-in way to mint a nonce, so the usual advice is "you can't nonce a static site." You can. You put an unguessable placeholder in your HTML and let nginx swap it for a fresh nonce on the way out.

The idea in one line: your HTML ships with a fixed placeholder token in every <script nonce="...">, and on each request nginx generates a per-request nonce, writes it into the CSP header, and uses sub_filter to replace the placeholder in the HTML with that same value. Same nonce in the header and the tags, fresh every time, from plain nginx.

New to nonces? How to set up a CSP nonce, per request covers the concept across app frameworks first; this post is the nginx-only, static-site version.

Why a static site needs a workaround

A CSP nonce has to be unique per response and unguessable, and the exact same value has to appear in two places: the script-src 'nonce-...' source and every trusted <script nonce="...">. If they do not match, the browser blocks the script.

An app framework does this naturally: it makes one random value per request and drops it into both the header and the template. A static site has no such step. The files on disk are fixed, and nginx serves them as-is. nginx also has no native nonce feature, so you have to manufacture the per-request value yourself and stitch it into the response. sub_filter (a lightweight response-body rewriter) plus nginx's per-request $request_id gives you both halves.

Step 1: put an unguessable placeholder in your HTML

Pick one random token, once, and use it as the nonce value on every trusted script in your static HTML. Generate it with anything that gives you a long random string:

Generate the placeholder token, once
openssl rand -hex 16

Say that gives you 9f2c8a1b7e4d60359f2c8a1b7e4d6035. Use it as your placeholder everywhere a script needs a nonce:

index.html
<script nonce="__csp_nonce_9f2c8a1b7e4d60359f2c8a1b7e4d6035__" src="/app.js"></script>
<script nonce="__csp_nonce_9f2c8a1b7e4d60359f2c8a1b7e4d6035__">init();</script>

The placeholder is a build-time constant, the same in your HTML and your nginx config. It is not the nonce; it is the marker nginx looks for and overwrites.

The placeholder must be unguessable, and that is a security requirement, not a style choice. If you use a well-known string like **CSP_NONCE** and any attacker-controlled content can reach the response body before nginx runs sub_filter, a reflected query value echoed into a page, a comment or profile field baked into "static" HTML, a server-side include, a CDN that rewrites the body, then an attacker can inject <script nonce="**CSP_NONCE**">evil()</script> and nginx will stamp the real, valid nonce onto their script. Their code becomes trusted and the nonce protection is gone. A long random token they cannot guess closes that door. sub_filter strips the placeholder out before the response is sent, so it never shows up in page source; guessing is the only way in, which is exactly what an unguessable token denies. A fully static site with no reflected content is lower risk, but the token costs nothing and keeps the technique safe if the site is ever not perfectly static.

Only put the placeholder on your own trusted scripts. Do not configure nginx to blindly nonce every <script on the page (see the caveats), because that would trust injected inline scripts too.

Step 2: mint and inject the nonce in nginx

Three directives do the work: capture a per-request value, rewrite the placeholder into it, and send the header with the same value.

nginx.conf
server {
    listen 443 ssl;
    root /var/www/static;

    # A fresh, unique value per request
    set $cspNonce $request_id;

    location / {
        # Replace the placeholder in every trusted tag with the nonce
        sub_filter '__csp_nonce_9f2c8a1b7e4d60359f2c8a1b7e4d6035__' '$cspNonce';
        sub_filter_once off;

        # Send the matching nonce in the policy
        add_header Content-Security-Policy "default-src 'self'; script-src 'nonce-$cspNonce' 'strict-dynamic'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'" always;
    }
}

$request_id is an nginx core variable: 16 random bytes rendered as 32 hex characters, generated fresh for each request, with no extra module. That is a valid nonce value: unique per response and unpredictable. sub_filter finds the placeholder in the HTML body and replaces it with $cspNonce, and sub_filter_once off makes it replace every occurrence rather than just the first. The add_header line puts the same $cspNonce into script-src as a nonce source, alongside 'strict-dynamic' so a trusted script can load the rest without you listing hosts.

The caveats that bite

This works, but a handful of nginx behaviors will silently break it if you miss them.

  • sub_filter needs its module. It comes from ngx_http_sub_module, built into most distro packages. If yours lacks it, nginx has to be compiled with --with-http_sub_module.
  • It only rewrites uncompressed HTML. sub_filter cannot see inside a gzipped body. Serving plain static files is fine, because nginx compresses the response after sub_filter runs. But if you serve pre-compressed .gz files with gzip_static, sub_filter never sees the markup, so the placeholder is not replaced. If you ever put this behind a proxied upstream, disable upstream compression for that location with proxy_set_header Accept-Encoding "";.
  • add_header does not always inherit. If a location block contains any add_header of its own, it stops inheriting add_header directives from the surrounding server or http block. So if your CSP is set higher up and a location adds some other header, the CSP quietly disappears. Set the header in the same block, and use always so it is sent on error responses too, not just 2xx and 3xx.
  • Only trust your own tags. The placeholder pattern already limits rewriting to the tags you marked. Do not switch to rewriting every <script, that would hand a valid nonce to any inline script in the page, including an injected one.
  • $request_id is unpredictable, not a textbook CSPRNG draw. Each nginx worker seeds a random key once and derives request ids from it, so the values are effectively unguessable and unique, which is what a nonce needs. If you want a value drawn straight from a cryptographic RNG, use the njs or OpenResty options below.

Pair it with strict-dynamic

The 'strict-dynamic' keyword in the example is what makes a nonce practical on a real site. With it, you only nonce your entry-point scripts; any script they load is trusted automatically, and host allowlists (and 'unsafe-inline') are ignored. Without it you would have to add the nonce to every single script tag, including ones your code injects at runtime, which you cannot reach from a static template. How strict-dynamic works covers the trade-offs. Keep object-src 'none' and base-uri 'none' alongside it, as in the config above, and build out the rest of the policy from a strict CSP starter template.

When to reach for something else

The $request_id and sub_filter combination is the quickest, dependency-free way to nonce a static site. Two situations call for something sturdier.

  • You actually have a backend. If nginx reverse-proxies an application you control, generate the nonce in the app instead. It already renders the HTML, so it can write one value into both the <script nonce> tags and the Content-Security-Policy header, and nginx just passes the header through. No body rewriting, no compression gotchas. Setting up a CSP nonce per request shows the app-side pattern for Express and Next.js.
  • You want a cryptographic-RNG nonce and can add a module. The njs module ships with nginx and can generate a base64 nonce with crypto.getRandomValues, set the header in js_header_filter, and rewrite the body in js_body_filter. OpenResty with Lua (resty.random.bytes plus ngx.ctx to share the value between the header and body phases) does the same. Both are more code than sub_filter, and both hit the same rule about not processing compressed bodies.

Validate what you shipped

After you deploy, confirm the header and the tags actually match on a live response. Run the URL through the CSP scanner to read the header nginx is sending, and the CSP evaluator to grade the policy and flag a weak script-src. Load the page and check the console: a nonce mismatch shows up immediately as a blocked-script violation, which is the fastest way to catch a placeholder that did not get replaced.

A console check only covers the pages you open yourself. Because a mismatched nonce is reported as an ordinary csp-violation, pointing the policy at a CentralCSP endpoint turns that one-off check into continuous coverage: if a cached response ever ships a stale placeholder, the violations arrive from real traffic rather than waiting for you to reload the right page.

Sources