Content Security Policy Examples for nginx, Cloudflare, and Next.js
Most Content Security Policy examples are a bare header string with no server attached. That is fine until you paste one into a config file and something downstream quietly eats it — a policy that never arrives looks exactly like a site with no policy at all.
This post carries one policy and deploys it three ways. Here it is, once:
Reporting-Endpoints: csp-endpoint="https://example.com/csp-reports"
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests; report-uri https://example.com/csp-reports; report-to csp-endpoint
# The same policy in Report-Only. Identical syntax, nothing is blocked, and the
# browser reports what it would have refused. Ship this name first, then rename
# the header once the reports go quiet.
Reporting-Endpoints: csp-endpoint="https://example.com/csp-reports"
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests; report-uri https://example.com/csp-reports; report-to csp-endpoint What each directive does, and which ones have no default-src fallback, is
covered in our Content Security Policy guide. This post picks up
after that: three stacks, the header, the nonce, and the ways both go missing.
Which mechanism does your stack use?
All three stacks set the same Content-Security-Policy response header. They
differ in one place that matters: whether the mechanism can produce a fresh
nonce per request. Stock nginx and Cloudflare Transform Rules cannot on their
own; a Cloudflare Worker and a Next.js proxy can.
| Stack | Where the header goes | Per-request nonce? | The one gotcha |
|---|---|---|---|
| nginx | add_header … always; in the server block | Not in stock nginx | A child location with its own add_header drops every inherited header |
| Cloudflare | Transform Rule → Modify Response Header | Only via a Worker | “Add” appends a second policy; “Set” overwrites — appending gives you the intersection |
| Next.js | headers() in next.config.js; nonce in proxy.ts | Yes, in proxy.ts | A nonce forces dynamic rendering — static optimization and ISR are disabled |
Apache and Caddy follow the same shape, and the CSP validator renders both from a policy you paste in — this post covers the three stacks where the deployment itself has teeth.
nginx: the add_header line, and the three ways it disappears
In nginx, set the policy with
add_header Content-Security-Policy "…" always; inside the server block. The
always flag is not optional: without it, nginx adds the header only when the
response code is 200, 201, 204, 206, 301, 302, 303, 304, 307 or 308 — so your
404 and 502 pages ship with no policy at all. The parameter has been available
since nginx 1.7.5.
# /etc/nginx/conf.d/example.com.conf
#
# Reporting-Endpoints is emitted first so a top-down copy gives `report-to` a
# URL to resolve against. `always` is not optional: without it nginx adds the
# header only on 200, 201, 204, 206, 301, 302, 303, 304, 307 and 308 -- so
# every 404 and 502 this server returns would ship with no policy at all.
server {
listen 443 ssl;
server_name example.com;
add_header Reporting-Endpoints "csp-endpoint=\"https://example.com/csp-reports\"" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests; report-uri https://example.com/csp-reports; report-to csp-endpoint" always;
location / {
proxy_pass http://app_upstream;
}
# This block declares an add_header of its own -- so nginx drops BOTH
# headers inherited from the server level, and this location serves with no
# CSP. The fix is mechanical: re-declare the full set here.
location /api/ {
add_header Cache-Control "no-store" always;
add_header Reporting-Endpoints "csp-endpoint=\"https://example.com/csp-reports\"" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests; report-uri https://example.com/csp-reports; report-to csp-endpoint" always;
proxy_pass http://api_upstream;
}
} Trap 1: inheritance is all-or-nothing. The ngx_http_headers_module docs
put it plainly — “These directives are inherited from the previous
configuration level if and only if there are no add_header directives defined
on the current level”
(nginx.org). One
unrelated add_header Cache-Control … in a location block discards the
entire inherited set, including your CSP. This is a common way an nginx CSP
ends up present on the homepage and absent on /api/.
Three fixes: re-declare the full set in every child block that adds anything
(shown above); switch inheritance with add_header_inherit merge, which appends
parent values to the current level and appeared in nginx 1.29.3; or stop
branching entirely with the map pattern below.
Trap 2: always and error paths. With error_page, add_header has to be
declared in the location that actually serves the error body, not the one that
generates the code. nginx trac #1324 (2017) was filed as always failing on a
return 451 and closed invalid for exactly that: the add_header sat in the
location doing the return, while error_page redirected internally to the
location that served the response. Verify with curl, not by reading the
config.
Trap 3: if blocks are a new context. An if block behaves like a nested
location and drops inherited add_headers the same way. Rather than fight it,
compute the value in a
map and emit a
single add_header outside any conditional:
# An `if` block is its own configuration context, so it drops inherited
# add_headers exactly the way a nested `location` does. The way out is to stop
# branching around the directive: compute the VALUE in a `map` (http context)
# and emit one add_header, outside any `if`.
map $request_uri $csp_policy {
default "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'";
# Pages that are meant to be embedded need a different frame-ancestors.
~^/embed/ "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors https://partner.example";
}
server {
listen 443 ssl;
server_name example.com;
# One directive, one level, no inheritance to lose.
add_header Content-Security-Policy $csp_policy always;
location / {
proxy_pass http://app_upstream;
}
}
# ---------------------------------------------------------------------------
# Alternative: more_set_headers, from the third-party headers-more-nginx-module
# (openresty/headers-more-nginx-module, current tag v0.40, 29 May 2026). Its
# inheritance works the other way round -- parent values survive into a child
# block -- and it applies to all status codes by default, so neither the
# re-declaration dance nor `always` is needed.
#
# more_set_headers 'Content-Security-Policy: default-src ...';
#
# It is a compile-time module, so it needs a custom nginx build or a
# distribution package that already bundles it. That snippet also names the alternative:
more_set_headers
from the third-party headers-more module, whose inheritance runs the other way
(parent values survive into child blocks) and which covers all status codes by
default. Its current tag is v0.40 (29 May 2026).
One trap we hit ourselves: nginx expands $ inside add_header with no in-band
escape, so a literal $ in a policy value stops nginx from starting, not
from serving. We found this building the validator’s deploy matrix, which now
warns when a policy contains one; the workaround is set $DOLLAR "$"; and then
${DOLLAR}. That same expansion is what makes an nginx nonce possible at all.
The nonce, honestly. Stock nginx has no cryptographically secure random
value to offer. The pattern people reach for is $request_id plus sub_filter,
and it works mechanically — but when Sergey A. Osokin suggested $request_id on
the
nginx mailing list,
nginx core maintainer Maxim Dounin replied the same day (19 October 2021) that
the request identifier “might not be cryptographically safe.” CSP Level 3 §7.1
says a nonce SHOULD be generated by a cryptographically secure random number
generator. Ship it knowing that, or source the value from njs, OpenResty/Lua,
set_secure_random_alphanum in set-misc-nginx-module, or the upstream
application.
# CAVEAT -- READ BEFORE SHIPPING THIS.
#
# $request_id is not a cryptographically secure random value. When Sergey A.
# Osokin suggested it on the nginx mailing list, nginx core maintainer Maxim
# Dounin replied (19 October 2021) that the request identifier "might not be
# cryptographically safe".
# CSP Level 3 section 7.1 says a nonce SHOULD be generated by a cryptographically
# secure random number generator. This pattern is mechanically correct and
# widely deployed, but it does not meet that requirement.
#
# If you need the guarantee, generate the nonce with njs, with OpenResty/Lua,
# with set_secure_random_alphanum from set-misc-nginx-module, or in the upstream
# application, and pass it down instead.
#
# Requires nginx built --with-http_sub_module.
server {
listen 443 ssl;
server_name example.com;
location / {
proxy_pass http://app_upstream;
# sub_filter rewrites the response body, so it has to be able to READ
# the body. Ask the upstream for uncompressed HTML or the filter
# silently matches nothing.
proxy_set_header Accept-Encoding "";
# Replace every placeholder, not just the first one on the page.
sub_filter_once off;
sub_filter 'NONCE_PLACEHOLDER' $request_id;
# The same value goes into the policy. nginx expands $request_id here
# the same way it does in sub_filter, which is what makes the two sides
# match.
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'nonce-$request_id' 'strict-dynamic'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always;
}
}
# Your templates emit the placeholder, and sub_filter swaps it per response:
#
# <script nonce="NONCE_PLACEHOLDER"> ... </script> Two details in that snippet make or break it: sub_filter_once off, so every
placeholder is replaced, and proxy_set_header Accept-Encoding "";, so
sub_filter sees uncompressed HTML rather than a gzip stream it cannot match.
Scott Helme’s
2017 write-up uses the
same sub_filter mechanics but takes the nonce from
set_secure_random_alphanum from the third-party set-misc module. It is not
the only in-nginx route to a CSPRNG — njs exposes crypto.getRandomValues and
OpenResty’s Lua bindings reach one too — but it is the shortest to write.
Cloudflare: Transform Rule or Worker?
Use a Response Header Transform Rule for a static policy and a Worker for a
per-request nonce. Transform Rules act on response headers; stamping a matching
nonce onto your <script> tags means rewriting the response body, which is a
different job.
Rules -> Transform Rules -> Modify Response Header -> Create rule
If incoming requests match... All incoming requests (or a custom filter expression)
Then... Set static <- NOT "Add static"
# Why the operation matters more than the value:
#
# Set static overwrites any existing header of that name -- Cloudflare's own
# wording is "overwriting its previous value or adding a new
# header". Exactly one policy survives.
#
# Add static is "without removing any existing headers with the same name".
# If your origin already sends a policy you now have two, and the
# browser enforces both at once as an intersection.
#
# Note that response header transform rules also apply to default Cloudflare
# error pages and Custom Errors, so a policy set here covers the 5xx pages your
# origin never gets to serve.
Header name: Reporting-Endpoints
Header value: csp-endpoint="https://example.com/csp-reports"
Header name: Content-Security-Policy
Header value: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests; report-uri https://example.com/csp-reports; report-to csp-endpoint The operation matters more than the value. Cloudflare’s own wording is that Set overwrites “its previous value or adding a new header,” while Add works “without removing any existing headers with the same name.” Pick Add while your origin is already sending a policy and you now have two — see the intersection section below. Usefully, these rules also apply to default Cloudflare error pages and Custom Errors, so they cover responses your origin never gets to serve.
For a nonce, Cloudflare’s
documented path is a Worker using HTMLRewriter,
which streams the response and sets the attribute without buffering the body:
/**
* Per-request CSP nonce at the edge.
*
* Based on Cloudflare's documented HTMLRewriter pattern -- the "Add CSP nonces"
* section of https://developers.cloudflare.com/workers/examples/spa-shell/
*
* A Transform Rule cannot do this: it works on response headers, and stamping a
* matching nonce onto your script tags means rewriting the HTML body.
*
* HTMLRewriter streams, so the body is never buffered in the Worker.
*
* Scope note: this sets a nonce on EVERY <script> element in the response,
* which is only safe for HTML you control end to end. It trusts the markup
* your origin produced -- it does not distinguish your scripts from anything
* injected upstream of the Worker.
*/
export default {
async fetch(request) {
const response = await fetch(request);
// Leave non-HTML responses (assets, JSON, redirects) untouched.
const contentType = response.headers.get("content-type") || "";
if (!contentType.includes("text/html")) return response;
const nonce = crypto.randomUUID();
const rewritten = new HTMLRewriter()
.on("script", {
element(el) {
el.setAttribute("nonce", nonce);
},
})
.transform(response);
rewritten.headers.set(
"Content-Security-Policy",
`default-src 'self'; script-src 'nonce-${nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'`,
);
return rewritten;
},
}; Cloudflare’s own scripts need entries in your policy, and this is a common
self-inflicted breakage. Per
Cloudflare’s CSP guidance:
Rocket Loader needs script-src 'self' ajax.cloudflare.com; Web Analytics needs
script-src static.cloudflareinsights.com plus
connect-src cloudflareinsights.com; and
Turnstile and Managed Challenge
need challenges.cloudflare.com in script-src and frame-src. Zaraz is
the exception — Cloudflare modifies the CSP itself to keep it running. Otherwise
Cloudflare states it does not modify an origin policy.
Two constraints before you pick a surface.
Content security rules
— Page Shield in older docs, Client-side security in the current dashboard —
only add headers, leaving any origin policy intact, and they do not support
nonce directives, so use a hash. Cloudflare Pages _headers is
static only, capped at
100 header rules and 2,000 characters per header.
Transform Rules counts are published inside a shared per-plan Rules bucket, so
confirm your own limit in the dashboard.
Next.js: next.config.js for a static policy, proxy.ts for a nonce
Set a static policy with headers() in next.config.js. For a per-request
nonce you need proxy.ts — the file convention Next.js 16 renamed from
middleware.ts — which generates the nonce, sets it on both the x-nonce
request header and the CSP header, and lets Next.js stamp it onto framework
scripts for you.
// next.config.js -- static policy, no nonce.
//
// Values here are fixed at build time, so there is no per-request value to put
// in a nonce. Inline scripts therefore need 'unsafe-inline', or the
// experimental SRI hashes. Follows the Next.js Content Security
// Policy guide, docs version 16.3.2 (last updated 2026-03-20).
const isDev = process.env.NODE_ENV === "development";
const cspHeader = `
default-src 'self';
script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""};
style-src 'self' 'unsafe-inline';
img-src 'self' blob: data:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
`;
module.exports = {
async headers() {
return [
{
source: "/(.*)",
headers: [
{
key: "Content-Security-Policy",
// A header value is a single line -- flatten the template literal.
value: cspHeader.replace(/\n/g, ""),
},
],
},
];
},
}; Because those values are fixed at build time, this route means 'unsafe-inline'
or the experimental SRI hashes. The nonce path is a different file:
// proxy.ts -- Next.js 16 renamed the middleware.ts file convention to proxy.ts
// and the exported function from `middleware` to `proxy`. middleware.ts still
// works but prints a deprecation warning; migrate with
// `npx @next/codemod@canary middleware-to-proxy .`
//
// Follows the Next.js Content Security Policy guide, docs version
// 16.3.2 (last updated 2026-03-20).
import { NextRequest, NextResponse } from "next/server";
export function proxy(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
const isDev = process.env.NODE_ENV === "development";
const cspHeader = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${isDev ? " 'unsafe-eval'" : ""};
style-src 'self' 'nonce-${nonce}';
img-src 'self' blob: data:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
`;
const contentSecurityPolicyHeaderValue = cspHeader
.replace(/\s{2,}/g, " ")
.trim();
// The REQUEST header is what Next.js reads during server rendering to find
// the nonce; the RESPONSE header is what the browser enforces. Both are
// required -- setting only the response header renders an unnonced page
// against a nonce policy.
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-nonce", nonce);
requestHeaders.set(
"Content-Security-Policy",
contentSecurityPolicyHeaderValue,
);
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set(
"Content-Security-Policy",
contentSecurityPolicyHeaderValue,
);
return response;
}
// Skip prefetches and static assets. Without this the proxy runs on every
// next/link prefetch the router fires as a user scrolls.
export const config = {
matcher: [
{
source: "/((?!api|_next/static|_next/image|favicon.ico).*)",
missing: [
{ type: "header", key: "next-router-prefetch" },
{ type: "header", key: "purpose", value: "prefetch" },
],
},
],
}; Once that header is set, Next.js
parses it, extracts the 'nonce-{value}'
and applies it automatically to framework scripts, page bundles, its own inline
scripts and styles, and any <Script> with the nonce prop. Read it in a
Server Component with (await headers()).get('x-nonce').
The naming change dates every competing tutorial. Next.js 16 renamed
middleware.ts to proxy.ts and the export from middleware to proxy;
middleware.ts still works but warns, and the codemod is
npx @next/codemod@canary middleware-to-proxy .
(docs). If an example
you found still says middleware.ts, it predates this.
The nonce has a documented price. The docs state you “must use dynamic
rendering to add nonces,” and list the consequences: static optimization and
Incremental Static Regeneration are disabled, pages cannot be cached by CDNs
without additional configuration, and Partial Prerendering is incompatible. Opt
an individual page in with await connection(). Separately, 'unsafe-eval' is
a development-only requirement — React uses eval for debugging output, and
neither React nor Next.js use it in production by default. All of this follows
docs version 16.3.2 (last updated 2026-03-20), which recommends
v13.4.20+ for nonce handling.
Two real failure modes are worth recognising by name. Under Turbopack with
output: 'standalone', no script in the response is stamped with the nonce —
the reproduction counts 12 <script> tags and zero nonce attributes — so a
nonce-present policy makes the browser ignore
'unsafe-inline' and block everything, and the page renders but never hydrates
(vercel/next.js #96063, 2026).
And a strict-CSP proxy runs on every link prefetch: one team reported
“~20-30 middleware execution per route just for a user scrolling down the whole
page. We reached our middleware execution month limit in just 3 days”
(#53928, 2023). The matcher
in the snippet above exists to prevent exactly that.
Two Content-Security-Policy headers? The strictest one wins
When more than one Content-Security-Policy header arrives, the browser
enforces each policy independently and the effective result is the intersection
— the most restrictive combination — not the last one to arrive. Two policies
whose allowlists do not overlap collapse toward 'none'.
The concrete case makes it click: img-src img.example.com in one policy and
img-src more-images.example.com in the other means no images load at all.
Neither host satisfies both policies, and both must pass
(content-security-policy.com).
In practice the duplicate comes from one of three places: the origin application emits a policy and the CDN adds a second; a Cloudflare Transform Rule set to “Add” instead of “Set”; or Page Shield content security rules, which add a header by design. All three are invisible in any single config file, which is why the check is on the wire and not in the repo:
# One policy, or two? Ask the server, not the config file.
$ curl -sI https://example.com | grep -i content-security-policy
content-security-policy: default-src 'self'; script-src 'self'; object-src 'none'
# One line back means one policy. Two lines mean two policies, and the browser
# enforces the intersection of both -- not the last one to arrive. Count them
# explicitly rather than eyeballing the output:
$ curl -sI https://example.com | grep -ci '^content-security-policy:'
1
# Now check a response that is NOT a 200. This is where a missing `always` on
# nginx shows up: the header is present on every real page and absent here.
$ curl -sI https://example.com/this-path-does-not-exist | grep -i content-security-policy What breaks on the day you enforce
Most first-day CSP breakage is not your own code. It is a third-party script that injects itself after the page is built, or a vendor that quietly moved an asset to a new host.
Rocket Loader injects its own inline <script> after the page is built, so
a nonce or hash policy has nothing to match it against. Teleport hit this
exactly: rocket-loader.min.js refused to execute under
script-src 'self' 'nonce-…'
(gravitational/teleport #38376,
February 2024). Allowlisting ajax.cloudflare.com does not fix it — a host
source never matches an inline script. Disable Rocket Loader for the zone, or
accept 'unsafe-inline' and lose the nonce’s value.
A vendor SDK bump moves assets. Mozilla’s own tracker records a OneTrust SDK
bump
(mozilla/bedrock #14118,
January 2024) that both broke the banner’s JavaScript and started pulling icons
from a host missing from img-src — the console named the exact blocked URL and
directive. The fix is allowlisting the new host; the lesson is that a third
party can break your policy without you deploying anything.
frame-ancestors in a <meta> tag is zero protection. CSP Level 3 ignores
frame-ancestors, report-uri, report-to and sandbox inside a
<meta http-equiv> policy
(mozilla/http-observatory #387
is one scanner’s 2019 encounter with it). Our CSP guide states that as an
authoring rule; the deployment consequence is that a <meta>-only rollout ships
with no clickjacking defence at all, however well the policy grades. Deliver it
as a response header.
None of this argues against enforcing. It argues for running the policy in
Content-Security-Policy-Report-Only first and reading what comes back.
Directives to delete from an old CSP example
If you copied a policy from a guide written before 2023, five of its directives now do nothing. Deleting them changes no behaviour and removes dead weight from a header you are already trying to keep under a buffer limit.
# Five directives to delete from any policy copied out of a pre-2023 guide.
# Removing them changes no behaviour in a current browser.
block-all-mixed-content; # No-op in Chromium, obsolete in Firefox and Safari.
# Passive mixed content is auto-upgraded and active
# mixed content is hard-blocked by default.
# -> use: upgrade-insecure-requests
plugin-types application/pdf; # Removed from CSP Level 3. Removed in Chromium,
# never implemented in Firefox.
# -> use: object-src 'none'
referrer no-referrer; # Obsolete in all engines. Firefox logs a
# deprecation notice for it.
# -> use: the Referrer-Policy response header
prefetch-src 'self'; # Not defined in any specification; removed from
# Chromium.
# -> no replacement needed
navigate-to 'self'; # Never shipped in any browser; removed from the
# CSP Level 3 spec.
# -> no replacement; form-action still covers the
# form-submission case block-all-mixed-content
is a no-op in Chromium and obsolete in Firefox and Safari: passive mixed content
is auto-upgraded and active mixed content is hard-blocked by default.
plugin-types was removed
from CSP Level 3 and from Chromium, and never implemented in Firefox. referrer
is obsolete in all engines, and
prefetch-src
is not defined in any specification. navigate-to never shipped anywhere.
One piece of old advice flipped the other way.
require-trusted-types-for
was Chromium-only for years, and every guide that told you to skip it is now
stale: Chrome has supported it since 83, Safari shipped it in Safari 26
(September 2025) and Firefox in 148 (February 2026), which is when it became
Baseline newly available. It is ignored rather than fatal where unsupported, so
it is safe to ship as a progressive enhancement.
FAQ
How do I add a Content-Security-Policy header in nginx?
Put add_header Content-Security-Policy "…" always; in the server block and
reload. The always flag makes it apply to error responses too. If any
location block declares an add_header of its own, repeat the full header set
there — nginx drops every inherited header in that block.
Why does my CSP header disappear on some pages?
Usually nginx inheritance. A location or if block that declares any
add_header of its own discards all headers inherited from the parent level.
Without always, the header is also omitted entirely on 4xx and 5xx responses.
Re-declare the set, or on nginx 1.29.3+ use add_header_inherit merge.
Should I use a Cloudflare Transform Rule or a Worker for CSP?
A Transform Rule for a static policy, a Worker for a per-request nonce.
Transform Rules act on response headers, so they cannot stamp a matching nonce
onto your script tags — that means rewriting the HTML body. Cloudflare’s
documented nonce pattern uses a Worker with HTMLRewriter.
Why do I have two Content-Security-Policy headers?
Your origin and your CDN are both emitting one. Browsers enforce each policy
independently and apply the intersection, so two non-overlapping allowlists
block almost everything. Emit exactly one — on Cloudflare use the “Set”
operation, not “Add” — and confirm with curl -sI.
Can I use a CSP nonce with Next.js static pages?
No. Next.js applies nonces during server rendering from the incoming request’s CSP header, so the docs require dynamic rendering. Static optimization and Incremental Static Regeneration are disabled, CDN caching needs extra configuration, and Partial Prerendering is incompatible. Use the experimental SRI hashes if you need static pages.
Conclusion
Content Security Policy examples are the quick part; getting the header to arrive on every response is the work. Four things carry across all three stacks:
- One policy string, three deployments. The same value carries unchanged
onto nginx and Cloudflare; the Next.js static route is the exception — with no
per-request value available it needs
'unsafe-inline'or SRI hashes. - nginx:
alwayson everyadd_header, and re-declare the full header set in any child block that adds anything of its own. - Cloudflare: a Transform Rule with the “Set” operation for a static policy,
a Worker with
HTMLRewriterwhen you need a nonce. - Next.js:
next.config.jsfor static,proxy.tsfor a nonce — and accept that the nonce costs you static rendering.
Then check it on the wire. A policy that grades well in a text box and never reaches the browser protects nobody.