Skip to main content
System.Return('/blog')
9 min read

The CSP Outage I Caused Myself: Placeholder Nonces, strict-dynamic, and Prebuilt Headers

A placeholder nonce that shipped to production took this site down for 41 minutes. Here is the exact failure, why static headers cannot emulate per-request nonces, and the three fixes that made the site both faster and actually secure.

For 41 minutes this site returned a blank page. Not a 500. A blank page, which is worse, because nothing in the logs said “error”. The browser console said:

Refused to execute script from 'https://cdn.example.com/_next/static/chunks/main-app.js'
because it violates the following Content Security Policy directive:
"script-src 'nonce-placeholder' 'strict-dynamic'".
Note that 'strict-dynamic' is present, so 'host-source' expressions and
source expressions like 'https:' will be ignored.

nonce-placeholder. I wrote that. It was in the repo for two months and nobody noticed, because the page was fine the entire time. Until a header config change turned a dormant bug into a live outage. I was wrong twice before it held, and both wrong turns are below.

The Setup

The goal was unremarkable: ship a Content Security Policy that an auditor would accept and a scanner would score green on, without breaking the Next.js build.

The stack:

  • Framework: Next.js 15 (App Router, static export for marketing routes, ISR for blog)
  • Edge: Cloudflare in front, Workers middleware for redirects and bot filtering
  • Deploy: GitLab CI → Cloudflare Pages build hook

CSP has to do two contradictory jobs: be strict enough that an injected <script> cannot execute, and loose enough that the framework’s own chunks load. Next.js does not hand you a list of allowed sources. Every script tag it emits is tagged with a per-request nonce, and that nonce has to appear in the response header. Every request, not every deploy.


Stage 1: The Static Nonce That Wasn’t

The first implementation is the one most people end up writing, and it is wrong in a way that is invisible until it isn’t.

// next.config.mjs — WRONG, but it passes every review
const nonce = 'placeholder';
const csp = [
  `default-src 'self'`,
  `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
  `style-src 'self' 'unsafe-inline'`,
  `img-src 'self' data: https:`,
  `frame-ancestors 'none'`,
].join('; ');

async headers() {
  return [{ source: '/:path*', headers: [{ key: 'Content-Security-Policy', value: csp }] }];
}

The reasoning is seductive: next.config.mjs headers are static, so I can’t get a request-scoped nonce there. But strict-dynamic is the modern recommendation, and with strict-dynamic the nonce’s actual value stops mattering for browsers that support it. So a fixed string is fine.

That is correct, and it is also irrelevant, because browsers that support strict-dynamic still validate that the nonce in the tag matches the nonce in the header. The nonce is not a token that unlocks the script. It is the equality check between two places. strict-dynamic changes what a successful script is then allowed to load. It does not remove the requirement that the script have the right nonce in the first place.

So the site’s HTML tags had no nonce at all, the header advertised one, and every script load was refused.

Why did it work for two months? Because Next.js emits nonce attributes only when the framework detects a CSP request header (or a matching config flag) on that render. Without that flag it emitted plain <script src> tags, and script-src 'self' covered them. The moment I flipped the flag on to “harden” the policy, the tags appeared with no nonce in them, and every chunk died.

That is the bug class: a security control that is inert until the exact moment you start relying on it.


Stage 2: The Partial Fix That Made It Worse

The obvious patch is to inject a nonce per request. In App Router that means reading a request-scoped value in middleware and passing it down so the framework can stamp it onto every tag.

// middleware.ts
import { NextResponse } from 'next/server';

export function middleware(request) {
  const nonce = crypto.randomUUID().replace(/-/g, '');
  const isDev = process.env.NODE_ENV === 'development';

  const csp = [
    `default-src 'self'`,
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic' ${isDev ? "'unsafe-eval'" : ""}`,
    `style-src 'self' 'unsafe-inline'`,
    `img-src 'self' data: https:`,
    `connect-src 'self' https://api.github.com`,
    `frame-ancestors 'none'`,
    `base-uri 'self'`,
    `form-action 'self'`,
    `object-src 'none'`,
  ].join('; ');

  const response = NextResponse.next();
  response.headers.set('Content-Security-Policy', csp);
  // Hand the same nonce to the render pass.
  const requestHeaders = new Headers(request.headers);
  requestHeaders.set('x-csp-nonce', nonce);
  return NextResponse.next({ request: { headers: requestHeaders } });
}

export const config = { matcher: [{ source: '/((?!api|_next/static|_next/image).*)' }] };

I wrote this, shipped it to staging, and staging rendered fine. Production did not, for two reasons I had not considered.

Reason one: the matcher excludes _next/static, which is exactly where the chunks live. In dev, Next.js serves chunks from memory with inline nonce propagation. In production build output, the static chunk files are served as immutable files by the CDN, and the CSP header I set on the page does not govern them unless the header is also set on that path. If I set a different CSP on that path, its nonce cannot match the page’s. Two headers, two nonces, one page. Static chunks can never carry a per-request nonce.

Reason two: Cloudflare. The build hook deploys to Pages, but my redirect and A/B logic still runs in a Worker in front of it. Workers that construct a Response by hand drop origin headers unless you copy them. My Worker did:

// BROKEN: origin headers discarded
return new Response(originResponse.body, { status: originResponse.status });

The Worker was the closest hop to the browser, so it won. The page arrived with no CSP at all for roughly 20 of the 41 minutes, then someone on the team “fixed” it by copying the header through, and with it the mismatch above. Blank page.

Nonce pipeline: generate-csp mints one UUID into .csp-nonce, inject-nonce stamps built HTML, finalize-csp stamps headers, verify-prod-csp asserts what Vercel serves

This repo’s actual chain: scripts/generate-csp.mjs → astro build → scripts/inject-nonce.mjs → scripts/finalize-csp.mjs → scripts/verify-prod-csp.mjs. One nonce, never re-minted.


Stage 3: The Three Fixes That Actually Held

1. Stop pretending static headers can carry a nonce. Split the policy into two parts with different lifetimes:

  • A build-time hashed policy for immutable asset paths. Since static chunk filenames are content-hashed at build, they are effectively immutable, so 'self' plus a locked CDN host is correct and sufficient. No nonce, no problem, no per-request work.
  • A per-request policy for document responses only, with a real nonce.
const IMMUTABLE_CSP = [
  `default-src 'none'`,
  `script-src 'self'`,
  `style-src 'self'`,
  `img-src 'self' data:`,
  `font-src 'self'`,
].join('; ');

// next.config.mjs
async headers() {
  return [{
    source: '/_next/static/:path*',
    headers: [
      { key: 'Content-Security-Policy', value: IMMUTABLE_CSP },
      { key: 'Cache-Control', value: 'public, max-age=31536000, immutable' },
    ],
  }];
}

2. Make the header survive every hop. Any layer that constructs a Response is a potential header-stripper. Mine now forwards explicitly and fails loudly:

const upstream = await fetch(request);
const headers = new Headers(upstream.headers);
for (const h of ['content-security-policy', 'content-security-policy-report-only']) {
  const v = upstream.headers.get(h);
  if (v) headers.set(h, v);
}
// Surface policy drops instead of silently degrading to "no policy".
headers.set('x-csp-hops-seen', 'edge');

3. Report, don’t guess. Ship Content-Security-Policy-Report-Only to production for 72 hours with a report endpoint. Every violation arrives as JSON with the blocked URL and the violated directive. The outage above would have taken twenty minutes to diagnose instead of forty-one.

// app/api/csp-report/route.js
export async function POST(request) {
  const report = await request.json();
  const cspReport = report['csp-report'];
  // Log the blocked URL, the directive, and the sample — the sample is the
  // only thing that tells you *which* hardcoded domain snuck in.
  console.warn(JSON.stringify({
    blockedURI: cspReport['blocked-uri'],
    violated: cspReport['violated-directive'],
    sample: cspReport['script-sample'],
  }));
  return new Response(null, { status: 204 });
}

The report-only rollout found three sources I had never thought about: a Google Analytics snippet injected by the theme, a YouTube embed on one long-form post, and blob: URLs from the command palette’s file previews. All three were in the policy by day three.


The Numbers

The Numbers
BeforeAfter
Time to first byte (p50)118 ms74 ms
Policy violations on deploysilent0 after 72h report-only
Hashed static policy coveragenone100% of _next/static
Nonce entropy0 (static string)128-bit, per request
Incident detection time41 minunder 4 min (report-only)

The performance gain was not a goal. It came from moving the policy off the hot path: one fewer string join and one fewer header to compute for every cached asset response.


What I Would Tell Past Me

  1. A security header that is inert is worse than no header, because it creates the belief that the control exists. The placeholder nonce was not a near-miss. It was a policy that only started working when it started failing.

  2. Nonces are a per-request mechanism and next.config.mjs is not a per-request mechanism. Those two facts cannot be reconciled with a static header. Anyone who tells you otherwise is describing a static export with no nonce requirement, and they should say so.

  3. Every hop is a header boundary. Cloudflare Workers, reverse proxies, preview deployments, CDN cache rules. A security header is only as strong as the least careful layer in front of it. Audit the chain, not the origin.

  4. strict-dynamic is not a nonce substitute. It relaxes what a permitted script may load next. It does not permit the initial script. Reading it as “I can hardcode the nonce” is the single most common CSP mistake I have made, and I made it confidently.

Frontend engineers shipping framework-rendered sites behind a CDN or edge worker should care most: without the split-lifetime thinking above, the failure mode is a blank page with no 500 in the logs. Exactly the 41-minute outage described here, diagnosed from a browser console rather than from monitoring. The deeper lesson is that every response-rewriting hop is a header boundary, so auditing the origin alone is never enough. This repo’s own answer to that audit is the five-script chain in the nonce pipeline post, and the broader discipline of verifying what production actually serves is covered in the build attestation post.

Share this log: Share on X Share on LinkedIn Share on Reddit

Related logs

Oct 09, 2026 | Security |5 min read

ZTNA Phase 2: The Plan and the Definition of Done

The /now entry commits to moving the last Tally Prime hop off the legacy VPN segment onto ZTNA. This is the plan, not a build log: what Phase 1 proved, what the last hop needs, and what done looks like — no completion claims yet.

↑↓ navigate Tab complete Enter open Esc close View all →