Nginx CSP Nonces with njs and sub_filter

This guide is part of the Nginx Security Headers Configuration reference. A nonce-based Content-Security-Policy authorises exactly the inline <script> tags whose nonce attribute matches a fresh, unguessable token minted per response. Nginx can mint that token and stamp it into both the Content-Security-Policy response header and the HTML body — but only if the same value reaches both, and only if the response is never cached. This page shows the $request_id approach, the njs module for RFC-shaped base64 nonces, the sub_filter body rewrite, and the proxy_cache bypass that keeps the whole scheme sound.

Configuration Syntax & Exact Values

A nonce is a base64-value (or any unpredictable token) that appears in two places at once. The header authorises it; the tag claims it. Both must carry the identical string, generated afresh for every request.

Content-Security-Policy: script-src 'nonce-cc7b1c3e2f9a4d10' 'strict-dynamic'; object-src 'none'; base-uri 'none'
<script nonce="cc7b1c3e2f9a4d10">/* trusted inline bootstrap */</script>

Annotated directive breakdown:

Nginx exposes a natural token source in $request_id: a 32-character hex string derived from 16 random bytes, unique per request. It requires no module and is already unique enough for a nonce.

# $request_id -> 16 random bytes rendered as 32 hex chars, e.g. cc7b1c3e2f9a4d10...
add_header Content-Security-Policy "script-src 'nonce-$request_id' 'strict-dynamic'; object-src 'none'; base-uri 'none'" always;

The always flag forces the header onto error responses (4xx/5xx) as well as 2xx. It matters here because a custom error_page may still serve HTML containing an inline script; without always, that error page loses its policy and either breaks or silently runs unprotected.

The value only becomes a working nonce once the same $request_id is written into the HTML body. The data flow below shows the single token fanning out to both sinks within one request.

Per-request nonce fan-out in Nginx A single $request_id value is written both into the Content-Security-Policy header and into the HTML body via sub_filter, so header and tag match. Request in Nginx mints $request_id One token cc7b1c3e2f9a4d10 CSP header 'nonce-...' add_header HTML body sub_filter into <script> Browser executes the inline script only when both strings are identical
The nonce is authoritative only when the header value and the body attribute derive from the same per-request token.

Server-Side Configuration

Nginx with $request_id and sub_filter

The simplest working setup uses $request_id for the header and sub_filter to substitute a placeholder in the upstream HTML with the same variable. Your application (or template) must emit a fixed placeholder such as **CSP_NONCE** on every inline script tag; Nginx swaps it for the live value on the way out.

server {
    # Header carries the nonce. 'always' keeps it on error pages that may hold inline script.
    add_header Content-Security-Policy
        "script-src 'nonce-$request_id' 'strict-dynamic'; object-src 'none'; base-uri 'none'" always;

    location / {
        proxy_pass http://app_upstream;

        # Rewrite every placeholder occurrence, not just the first.
        sub_filter_once off;
        # Only rewrite HTML; never touch JSON, JS, or binary bodies.
        sub_filter_types text/html;
        # Placeholder -> live per-request token. Must equal the header's $request_id.
        sub_filter '**CSP_NONCE**' '$request_id';

        # sub_filter cannot operate on compressed upstream bodies.
        proxy_set_header Accept-Encoding "";
    }
}

Template side, every inline script the app renders must use the exact placeholder:

<script nonce="**CSP_NONCE**">/* bootstrap */</script>

sub_filter_once off is load-bearing: with the default on, only the first placeholder is replaced and every later inline script ships an untouched **CSP_NONCE** that matches nothing and is blocked. sub_filter_types text/html narrows the rewrite to documents so you never corrupt a JSON body that happens to contain the placeholder string. Clearing Accept-Encoding toward the upstream is mandatory because sub_filter operates on the decoded byte stream — a gzip- or brotli-compressed upstream body passes through untouched and the substitution silently never happens.

Nginx with njs for a base64 nonce

$request_id is hex, not the base64 shape some policies and audit tools expect. The njs module lets you compute a standards-shaped base64url nonce from the same random source and expose it as a variable used by both the header and sub_filter. This keeps a single value per request while producing a canonical token.

// /etc/nginx/njs/nonce.js
function cspNonce(r) {
    // Derive 16 random bytes from the request id (already 16 bytes of entropy),
    // then base64url-encode. One value, reused by header and body.
    var hex = r.variables.request_id;          // 32 hex chars = 16 bytes
    var bytes = Buffer.from(hex, 'hex');
    return bytes.toString('base64')
        .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

export default { cspNonce };
# http { } context
js_import nonce from njs/nonce.js;
js_set $csp_nonce nonce.cspNonce;

server {
    add_header Content-Security-Policy
        "script-src 'nonce-$csp_nonce' 'strict-dynamic'; object-src 'none'; base-uri 'none'" always;

    location / {
        proxy_pass http://app_upstream;
        sub_filter_once off;
        sub_filter_types text/html;
        sub_filter '**CSP_NONCE**' '$csp_nonce';
        proxy_set_header Accept-Encoding "";
    }
}

A js_set variable is evaluated once per request on first use and cached for that request, so referencing $csp_nonce in both the add_header and the sub_filter yields the identical string — exactly the invariant a nonce requires. The following state machine shows how a single request either lands in the matched (executing) state or one of the two failure states.

Nonce match state machine A request resolves to executed, blocked-on-mismatch, or blocked-on-missing depending on whether the body token equals the header token. Header nonce set 'nonce-T' in CSP Body tag parsed nonce attr = ? Blocked: missing no attr / placeholder left Executed attr = T Blocked: mismatch attr = stale token Only the centre path executes; both failure paths are a caching or substitution bug
Every blocked inline script traces back to a body token that does not equal the header token — usually a cache hit or a missed substitution.

Diagnostic & Verification Steps

Confirm that header and body carry the same token and that the token changes between requests. First, read the header and the body in one shot and eyeball both nonces:

curl -s -D - https://example.com/ -o body.html | grep -i content-security-policy
grep -o 'nonce="[^"]*"' body.html | head -1

Expected — the two values are identical:

content-security-policy: script-src 'nonce-cc7b1c3e2f9a4d10...' 'strict-dynamic'; object-src 'none'; base-uri 'none'
nonce="cc7b1c3e2f9a4d10..."

Second, prove the nonce is per-request by hitting the origin twice and comparing only the header nonce; the two must differ:

for i in 1 2; do
  curl -s -D - -o /dev/null https://example.com/ \
  | grep -io "nonce-[a-z0-9_-]*"
done

Expected — two distinct tokens:

nonce-cc7b1c3e2f9a4d10a1b2c3d4e5f60718
nonce-9f4e2a71b0c3d58e6a1f072c4b9d3e08

If the two lines are identical, a cache is serving a stored response and the nonce is frozen — every visitor after the first shares one token, which is an injection bypass. Third, watch the browser enforce it: open DevTools, and a mismatch surfaces in the Console as a refused-execution report.

Refused to execute inline script because it violates the following
Content Security Policy directive: "script-src 'nonce-...'".
Either the 'unsafe-inline' keyword, a hash, or a nonce is required to enable inline execution.

Edge Cases, Security Implications & Safe Rollback

Caching destroys nonces — bypass it for HTML. A nonce is valid for exactly one response. If proxy_cache (or a CDN, or microcache) stores a nonced HTML document, the stored header and stored body are replayed to every subsequent visitor with a token that never rotates. That is functionally a static nonce, i.e. no protection at all. HTML responses that carry a nonce must never be cached. Bypass the cache explicitly for any location that noncing touches:

location / {
    proxy_pass http://app_upstream;
    # Never store or serve a nonced HTML response from cache.
    proxy_cache_bypass 1;
    proxy_no_cache 1;
    add_header Cache-Control "no-store" always;

    sub_filter_once off;
    sub_filter_types text/html;
    sub_filter '**CSP_NONCE**' '$csp_nonce';
    proxy_set_header Accept-Encoding "";
}

The Cache-Control no-store value stops downstream shared caches and the browser from retaining the document. Cache static assets aggressively elsewhere; just keep the noncing HTML path uncacheable.

Compression silently voids sub_filter. If the upstream returns a gzip or brotli body, sub_filter sees compressed bytes, finds no **CSP_NONCE** placeholder, and passes the body through with the placeholder still literally present. The header then advertises a nonce that no script claims and every inline script is blocked. Clearing Accept-Encoding toward the upstream (shown above) forces an uncompressed body so the substitution can run; let Nginx re-compress with gzip on; after filtering.

$request_id entropy and shape. $request_id is 16 random bytes — sufficient entropy for a nonce — but it is hex, and it is also emitted in access logs by many setups. Logging the exact nonce is low-risk because the token is single-use and dead by the time a log is read, but if your policy requires base64url shape or you want the value kept out of logs, use the njs path and avoid logging $csp_nonce.

Rollback. Noncing is non-destructive and instantly reversible. Ship the policy as report-only first so violations are reported without blocking. To roll back, swap the enforcing header for the report-only variant, or drop the nonce source entirely:

# Observe without enforcing: violations report, nothing is blocked.
add_header Content-Security-Policy-Report-Only
    "script-src 'nonce-$csp_nonce' 'strict-dynamic'; object-src 'none'; base-uri 'none'; report-uri /csp-report" always;

The rollout timeline below sequences the safe path from staging to enforced production.

Nonce rollout timeline Four ordered stages from staging through report-only observation to enforced production, with rollback available at every stage. 1. Staging verify header = body token 2. Report-only collect violation reports, no block 3. Enforce no-store on HTML, cache bypassed 4. Prod monitor reports Rollback to report-only is a one-line header swap at any stage
Prove the token invariant in staging, observe in report-only, then enforce with caching disabled on the nonced path.

Conclusion

Stand the nonce up in staging first and prove the header token equals the body token across two requests and that the token rotates. Promote it as Content-Security-Policy-Report-Only with a short-lived reporting endpoint so real traffic surfaces any inline script you missed without breaking the page, then flip to the enforcing header — with no-store and cache bypass on every nonced HTML path — for full production. The single non-negotiable invariant throughout: one fresh token per response, reaching both the header and the body, never cached.

Frequently Asked Questions

Can I use $request_id directly as a CSP nonce? Yes. $request_id is 16 random bytes rendered as 32 hex characters and is unique per request, which satisfies the unpredictability and per-response freshness a nonce requires. Its only shortfalls are cosmetic: it is hex rather than base64url, and it appears in access logs by default. Use the njs path if you need canonical base64 shape or want the value kept out of logs.

Why is my inline script blocked even though the header has a nonce? The body token does not match the header token. The usual causes are a cached HTML response replaying a stale nonce, a compressed upstream body that sub_filter could not rewrite (leaving the literal **CSP_NONCE** placeholder in the tag), or sub_filter_once left at its default on so only the first script was rewritten. Check that the header nonce and the nonce="..." attribute are byte-for-byte identical.

Does sub_filter work on gzip-compressed responses? No. sub_filter operates on the decoded response body and cannot match a placeholder inside compressed bytes. Send proxy_set_header Accept-Encoding ""; to the upstream so it returns an uncompressed body, let Nginx perform the substitution, then re-enable gzip on; in Nginx to compress the filtered output on the way to the client.

Can I cache nonced HTML if I set a long TTL? No. A nonce is valid for exactly one response; caching the HTML freezes that token and replays it to every later visitor, which is equivalent to a static nonce and provides no protection. Set no-store, proxy_no_cache 1, and proxy_cache_bypass 1 on the nonced HTML path. Cache static assets aggressively on separate locations instead.

Do I still need object-src and base-uri when using strict-dynamic? Yes. strict-dynamic only governs script-src; it does not restrict plugin content or the document base URL. Without object-src 'none' an attacker can inject <object>/<embed> payloads, and without base-uri 'none' they can rewrite the base and subvert relative script URLs. Both are required for a nonce policy to actually contain script execution.