Most of a Content Security Policy is free. The rest costs you static rendering.
A security questionnaire asks for a CSP, you copy the nonce recipe everyone publishes, and your statically generated site quietly becomes a dynamically rendered one. Which directives cost nothing, why the nonce is the expensive part, and how we split a policy so the security lands where it actually matters.
The e-mail was four pages of PDF from a client's new insurer, and one line of it concerned us: "The application does not set a Content-Security-Policy header." No exploit, no proof of concept, just an absence on a checklist. The client asked how long it would take. We said a day, because that is what it looks like — a header is a string, and the internet is full of copy-paste recipes for producing it.
The recipe we reached for is the one every Next.js tutorial publishes: generate a random nonce in middleware — proxy.ts, since Next 16 renamed it — stamp it onto the policy, read it back in the root layout, hand it to the script tags. It works. Lighthouse turns green. And the build output at the bottom of the terminal changed from ninety-four lines marked ○ (Static) to ninety-four lines marked ƒ (Dynamic), which nobody noticed until the first deploy made the site measurably slower than the one it replaced.
That is the trade this post is about, and it is not a Next.js quirk. A nonce is by definition unique per response. A page that is unique per response is not a file. Everything downstream of "it's a file" — build-time rendering, an unconditional cache hit at the edge, a pod that serves the same bytes to four hundred people without doing any work — is what you are spending. The mistake is not that the spend is never worth it. It's that a CSP is usually discussed as one thing you either have or don't, when it is really four separate jobs with wildly different price tags, and almost all of the protection sits in the jobs that are free.
A CSP is four jobs wearing one header
Read a real policy and the directives sort themselves into groups by what they defend against.
What may execute. script-src, and to a lesser degree style-src. This is the anti-XSS directive, the one the OWASP cheat sheets are about, and the only one that needs to know something about the specific bytes of the specific response. It is also the expensive one.
Where resources may come from. img-src, font-src, connect-src, frame-src, media-src. Static strings listing hostnames. These do not stop an injection, they contain one — a script that already got in cannot exfiltrate to an endpoint you did not name, and connect-src is the directive that makes a stolen form payload undeliverable.
Where this page may be embedded. frame-ancestors. Clickjacking, properly handled — the header it replaces, X-Frame-Options, cannot express "these two partner origins and nobody else" and is ignored inside a Content-Security-Policy by modern browsers anyway.
What the page may point at. base-uri, form-action, object-src. The quiet ones, and per line of configuration the best value in the whole header. base-uri 'self' stops an injected <base> tag from silently re-rooting every relative script URL on the page to an attacker's host. form-action 'self' stops injected markup from re-pointing your contact form at somebody else's collector — worth thinking about for a minute if, like ours, your forms create records in an ERP. object-src 'none' turns off a plugin surface no site built this decade uses.
Three of those four groups are constant strings. They are the same for every visitor, every response and every page. They cost exactly nothing to serve, they survive any amount of caching, and you can put them on a static site hosted on a bucket. Only the first group — script-src — is the one that makes your renderer's life hard, and it is the one the recipes lead with.
Why the nonce costs what it costs
A nonce is a cryptographically random value, generated fresh per response, that appears in two places: the policy header and the nonce attribute of each inline script you are willing to vouch for. The browser runs the script if and only if the two match. That is a good design — it is unguessable, so injected markup cannot forge it.
It is also a per-response secret, which has consequences that outrank the CSP itself:
Reusing a nonce across responses is worse than having no nonce. If two visitors get the same value, an attacker is one page fetch away from knowing a nonce that will still be accepted when their injected script arrives. So the value must be generated per request, must never be baked into build output, and must never be cached. Any layer between you and the browser that stores whole HTML responses — a CDN page cache, a reverse proxy, Next's own ISR cache — becomes a correctness bug the moment a nonce is in the body.
Reading it makes the page dynamic. In the App Router the nonce is minted in the one place that sees every request before anything renders, and the only way to get it from there into your JSX is to read the request. Everything that reads a request is, by construction, excluded from build-time rendering. In Next 16 this is no longer something you have to infer from the build table — with cacheComponents on, a component that reads headers() simply cannot live inside a "use cache" boundary, and the framework tells you so. The framework is not being difficult. It is telling you that a value which differs per request cannot be inside a thing you wanted to compute once.
So the price of a strict script-src is that the first ƒ in the build table spreads to every route that shares a layout with the thing that read the nonce — which, since the nonce goes in the root layout, is all of them. For a static-first marketing site, that is the entire architecture.
Hashes don't rescue static output either
The documented alternative to nonces is hashes: instead of vouching for a script with a shared secret, you list the SHA-256 of its exact contents in the policy. Hashes are build-time values, so in principle this is the static-friendly path.
In practice, for the App Router, it is a maintenance liability. React Server Components stream their payload into the page through inline bootstrap scripts — the self.__next_f.push(...) calls in your HTML source. Their contents embed the serialized payload, so the hash of an inline script on /blog/some-post changes when you fix a typo in that post. A per-page hash implies a per-page header, computed from build output, and something in your deploy pipeline that keeps them in sync forever. We have built it. We would not build it again for a brochure site.
One detail worth internalising while you're here, because it bites everybody once: as soon as a policy contains a nonce or a hash, browsers ignore 'unsafe-inline' in that same directive. It is a compatibility fallback for browsers too old to understand the strict form, not an escape hatch for the ones that do. The symptom is a policy that looks permissive and behaves strictly, and half a day lost to "but I explicitly allowed inline."
We split the policy by what it costs
The conclusion we reached on that client's portal, and have applied to every project since, is that a CSP is not one decision. It is three, taken in order of increasing price.
Tier one: the free directives, everywhere, on day one. No inventory, no testing, no rendering cost. There is no reason for any site to be missing these, and they are most of what the questionnaire was actually asking about.
nginx.ingress.kubernetes.io/configuration-snippet: |
more_set_headers "Content-Security-Policy: default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'; upgrade-insecure-requests";Tier two: the source lists, after you inventory what you actually load. Still constant strings, still free to serve, but they require knowing every host your pages talk to — and that is real work rather than configuration, which is the next section.
Tier three: a strict script-src, only where it earns its price. On an authenticated portal where a successful XSS reads somebody else's invoices, paying with dynamic rendering is obviously correct: those pages were per-user anyway, and there was never any static output to lose. On a public marketing site, where the worst case is defacement of pages that contain no secrets and the cost is the entire caching story, it is just as obviously wrong. Same client, same codebase, different answer per route — and because the expensive part lives in a layout, "per route" in practice means deciding which route groups get the nonce-bearing layout and which don't.
That split is the whole trick. It also reframes the conversation with whoever sent the questionnaire, because "we have a CSP" stops being a yes/no and becomes a list of what it does.
The inventory is the actual project
Writing img-src is five minutes. Knowing what belongs in it is the job, and on a site of any commercial age the answer has drifted well past what anyone remembers.
Ours is a reasonable example. Images are served from a CDN origin rather than the app, so img-src has to name that host or every photo on the site vanishes. The booking page embeds a third-party scheduler in an iframe, so frame-src needs it. Product analytics loads only after consent, so connect-src needs an endpoint that, for a visitor who declined, is never contacted at all — your policy has to describe a superset of what any single visitor experiences. Fonts, the theme switcher's inline bootstrap, a WebGL globe pulling in a texture: each one is a line, and each line is a thing that breaks silently if you get it wrong.
Two properties of that list make it dangerous. It is invisible in code review — nothing in a pull request that adds a marketing tag says "this also needs a CSP change," and the person adding the tag is frequently not the person who knows the CSP exists. And it is enforced in the one place you cannot see, the visitor's browser, where a violation is a console message on somebody else's laptop rather than an error in your logs.
Which is why the rollout matters more than the policy.
Report-only, and somewhere for the reports to go
Ship the policy in Content-Security-Policy-Report-Only first. Browsers evaluate it exactly as they would the real thing, block nothing, and POST a JSON report for every violation. Run it for two weeks minimum, and make sure those weeks include a marketing campaign, a newsletter send, and at least one non-Chrome browser on a real phone.
Reporting-Endpoints: csp="https://example.be/api/csp-report"
Content-Security-Policy-Report-Only: default-src 'self'; ...; report-to csp; report-uri /api/csp-reportThen budget for the part nobody mentions: most of your reports will be noise. Browser extensions inject scripts and styles into every page their user visits, and those injections violate your policy as enthusiastically as an attacker would. Password managers, ad blockers, accessibility tools, corporate MITM proxies, and — reliably — the translate feature in a mobile browser. A collector that does not drop violations whose blocked-uri is an extension scheme, or which are inline violations on style-src from a document your code did not produce, will bury the four real findings under four thousand fake ones, and the endpoint will be muted within a week.
The real findings are worth the filtering. Ours, on that portal, were: a font host nobody had documented, a legacy tracking pixel still firing from a template we thought we'd deleted, and one genuinely useful discovery — a third-party widget quietly loading a second script from a domain that was not the vendor's.
Where to set the header
Both places, for different parts, and this is a distinction worth being pedantic about.
The constant directives belong at the edge — the ingress, the CDN, whatever terminates TLS. Set there, they cover responses your application never sees: static files, redirects, and the error pages the proxy returns while a rollout is between versions. Set in the application, they cover only what the application renders, which behind an ingress is a smaller set than people assume.
The nonce-bearing script-src can only come from the application, because only the application knows the nonce.
The trap is setting a full policy in both places. When a browser receives two Content-Security-Policy headers it enforces both, independently — the effective policy is the intersection, and a resource has to satisfy every policy present. So an ingress-level default-src 'self' plus an app-level policy that correctly permits your CDN produces a page with no images and no obvious culprit, because each header looks right on its own. If you take one operational thing from this post, take that one: it costs an afternoon every time, and it always presents as "it works locally."
What we run, and what we tell clients
On the public site: tier one and tier two, set at the ingress, enforced. script-src is not yet strict and we say so out loud rather than shipping a policy that merely looks strict — a script-src containing 'unsafe-inline' blocks nothing and mostly serves to make a scanner quiet, which is a worse outcome than an honest gap because it stops anyone from looking again.
On the custom applications we build and host: tier three as well, nonce and all, on the authenticated routes. Those pages are per-user by definition, so dynamic rendering costs nothing that was ever available, and the threat model is completely different — an injected script there reads real data belonging to a real person.
And when a questionnaire arrives, the answer is no longer a single line. It is: here is the policy, here is which directives are enforced where, here is why the public pages stop short of a strict script-src, and here is the endpoint where violations land. In our experience that answer passes review more comfortably than a perfect-looking header nobody can explain — insurers and procurement departments are considerably better at spotting an unjustified claim than an incomplete one.
The short version
A Content Security Policy is not one control. frame-ancestors, base-uri, form-action and object-src close four real attack classes, take ten minutes, cost nothing at runtime and work on a site served entirely from a bucket — there is no architecture on earth that cannot afford them, and a site missing them is missing the cheap protection while arguing about the expensive one.
The expensive one is a strict script-src, and it is expensive for a structural reason rather than a framework one: vouching for scripts per response means rendering per response. Pay it where an injected script would reach someone's data. Don't pay it to make a scanner happy on pages that are the same for everybody.
Between those two sits the work that actually takes the time — knowing every host your site talks to, watching report-only traffic long enough to catch the ones that only appear during a campaign, and filtering out the browser extensions before they drown the signal. That part doesn't get shorter with a better framework. It gets shorter with an inventory somebody keeps.
Want us to publish something specific?
Tell us what you'd like to read and we'll add it to our writing queue.
Get the next one in your inbox
New articles, videos and the occasional engineering note — a short mail when there’s something worth reading, nothing else.