Skip to main content
by Meysam Azad
16 min read

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:

The policy every example below deploys http
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.

Verdict
Mechanism, nonce support, and the failure mode each stack is known for.
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
Verdict Static policy → any of the three, and the header is one line. Per-request nonce → nginx needs njs, Lua, or the upstream application; Cloudflare needs a Worker; Next.js needs proxy.ts and gives up static rendering in exchange.

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.

nginx: the server-block header pair, and the inheritance fix nginx
# /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:

nginx: the map escape hatch, plus the headers-more alternative nginx
# 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.

nginx: $request_id nonce — caveat included in the file nginx
# 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.

Flowchart of one request through nginx: the server block sets Content-Security-Policy, then two checks decide whether it survives — does the matched location or if block declare any add_header of its own, and is the response 4xx or 5xx with no always parameter. Either yes means no policy on the wire.
The two checks between an add_header line and a policy the browser actually enforces. Either branch produces a response indistinguishable from a site with no CSP at all.

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.

Cloudflare: the Transform Rule fields to paste text
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:

Cloudflare Worker: per-request nonce via HTMLRewriter javascript
/**
 * 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 javascript
// 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: per-request nonce (Next.js 16 naming) typescript
// 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.

Three lanes comparing where a per-request CSP nonce comes from: nginx uses $request_id with sub_filter, which is not a CSPRNG, so a real one needs njs, OpenResty Lua, set_secure_random_alphanum or the upstream application; Cloudflare cannot do it with a Transform Rule and needs a Worker using HTMLRewriter; Next.js generates it in proxy.ts and stamps it during server rendering, at the cost of dynamic rendering.
Where the nonce value is born on each stack, and what each path costs. The policy string is the same in all three lanes — only the source of the nonce differs.

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:

Confirm exactly one policy is on the wire bash
# 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 that no longer do anything text
# 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: always on every add_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 HTMLRewriter when you need a nonce.
  • Next.js: next.config.js for static, proxy.ts for 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.