# How to add a fresh CSP nonce to a static site with nginx (/en/blog/csp-nonce-nginx)



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](/en/blog/csp-nonce-setup) covers the concept across app frameworks first; this post is the nginx-only, static-site version.

## Why a static site needs a workaround [#why-a-static-site-needs-a-workaround]

A CSP nonce has to be [unique per response and unguessable](/en/docs/web-security/policies/content-security-policy/values/csp-hashes-nonce), 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 [#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:

```bash title="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:

```html title="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 [#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 title="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 [#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 [#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](/en/blog/strict-dynamic-csp) 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](/en/blog/csp-starter-template).

## When to reach for something else [#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](/en/blog/csp-nonce-setup) shows the app-side pattern for Express and Next.js.
* **You want a cryptographic-RNG nonce and can add a module.** The [njs module](https://nginx.org/en/docs/http/ngx_http_js_module.html) 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 [#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](/tools/csp-scanner) to read the header nginx is sending, and the [CSP evaluator](/tools/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.

## Related [#related]

* [How to set up a CSP nonce, per request](/en/blog/csp-nonce-setup), the app-framework version
* [strict-dynamic explained](/en/blog/strict-dynamic-csp)
* [A CSP starter template you can copy and tighten](/en/blog/csp-starter-template)
* [Nonces and hashes](/en/docs/web-security/policies/content-security-policy/values/csp-hashes-nonce), the reference

## Sources [#sources]

* [nginx, ngx\_http\_sub\_module (sub\_filter)](https://nginx.org/en/docs/http/ngx_http_sub_module.html)
* [nginx, ngx\_http\_core\_module ($request\_id)](https://nginx.org/en/docs/http/ngx_http_core_module.html)
* [nginx, ngx\_http\_headers\_module (add\_header)](https://nginx.org/en/docs/http/ngx_http_headers_module.html)
* [web.dev, Mitigate XSS with a strict CSP](https://web.dev/articles/strict-csp)
* [OWASP, Content Security Policy cheat sheet](https://cheatsheetseries.owasp.org/cheatsheets/Content_Security_Policy_Cheat_Sheet.html)
