All tutorials

Tutorial

Content Security Policy and Security Headers for Next.js: A Production Hardening Checklist

A senior engineer's ordered checklist for shipping Content Security Policy and security headers in Next.js without breaking production.

nextjssecurity

A default Next.js app, straight out of create-next-app, ships without most of the security headers that a public-facing product actually needs. That’s not a Next.js failing — it’s a framework choosing not to guess at your threat model. But it means the checklist is yours to run, and the order you run it in matters more than most teams assume.

Here’s the direct answer, then the reasoning: start with the low-risk HTTP security headers (they take an afternoon and fix most of your score), get HTTPS and framing locked down, and only then tackle CSP — because CSP is the one header that can genuinely break your app if you ship it wrong. For CSP specifically, your rendering model decides your options: dynamic routes can use per-request nonces, static routes generally can’t, and third-party scripts will force compromises in both. Roll it out in report-only mode first, with a reporting endpoint that won’t fall over, then flip to enforced once the violations stop surprising you.

The rest of this article walks that sequence in order, with the failure stories that justify each step.

What CSP actually protects against

Content Security Policy is a response header that tells the browser which origins are allowed to supply scripts, styles, images, fonts, frames, and other resources for a page. The browser enforces it, not your server — once the header is sent, it’s the client’s job to block anything that doesn’t match. That distinction matters: CSP is a boundary the browser polices at load/execute time, not a gate your application logic controls, and it sits alongside — not instead of — input validation, output encoding, authentication, and dependency hygiene. If you have a stored-XSS bug, CSP can stop the injected script from phoning home or executing; it does not stop the bug from existing.

The threats it’s aimed at in a Next.js context are the familiar client-side ones: cross-site scripting, clickjacking, and code injection via untrusted script sources. These aren’t hypothetical categories invented for compliance checklists. For example, if malicious script were injected into a checkout page — whether through a compromised dependency or an XSS hole — it could quietly exfiltrate form data to an attacker-controlled domain. A tight connect-src and script-src wouldn’t have stopped the initial compromise, but it’s exactly the kind of control that limits where exfiltrated data can go once malicious script is running in the page. That’s the honest pitch for CSP: not “prevents hacks,” but “reduces blast radius when something gets through.”

Step 1: the easy wins — non-CSP security headers

Before touching CSP, get the boring headers in place. They’re low-risk, well-understood, and they move your score more than people expect.

In one reported account, a Next.js application scored an “F” on SecurityHeaders.com due to missing headers — no HSTS, no Referrer-Policy, no Permissions-Policy, no X-Frame-Options, no X-Content-Type-Options, and no CSP at all. According to that account, adding all of those except CSP — which was left in report-only mode — brought the grade up to an “A” on rescan. If accurate, that’s the shape of the win: the non-CSP headers are close to free, and they can account for most of the visible security posture improvement before CSP even enters enforced mode. Worth verifying against your own scan rather than assuming the same jump.

In Next.js, these headers are set with the headers() function in next.config.js (or .ts), returned as an async array matching a source pattern like /(.*), or via a platform-specific file such as vercel.json on Vercel:

// next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: [
          { key: 'X-Content-Type-Options', value: 'nosniff' },
          { key: 'X-Frame-Options', value: 'DENY' },
          { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
          { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
          {
            key: 'Strict-Transport-Security',
            value: 'max-age=63072000; includeSubDomains; preload',
          },
        ],
      },
    ]
  },
}

A few notes on what each of these buys you:

  • X-Content-Type-Options: nosniff stops the browser from guessing a resource’s type from its content — with nosniff set, a script request that isn’t actually JavaScript, or a stylesheet request that isn’t text/css, gets blocked rather than sniffed and executed anyway, which closes off a class of MIME-confusion attacks.
  • X-Frame-Options: DENY (or CSP’s frame-ancestors, which supersedes it) is commonly used, per MDN, to stop pages being embedded in someone else’s iframe for clickjacking — worth testing on your own pages to confirm the behaviour you expect.
  • Strict-Transport-Security, per MDN, tells browsers to only connect over HTTPS going forward — but treat includeSubDomains as a one-way door: only add it once you’re sure every subdomain actually supports HTTPS, since HSTS with subdomains applies broadly and is hard to walk back cleanly. Test on a staging domain before rolling it out broadly.
  • Referrer-Policy and Permissions-Policy limit what leaks in the Referer header and which browser APIs (camera, mic, geolocation) your origin can request, respectively.

This is also where cross-origin isolation headers live if you need them — Cross-Origin-Opener-Policy, Cross-Origin-Embedder-Policy, and Cross-Origin-Resource-Policy — but they’re a narrower call than the headers above. COOP is commonly reported to interfere with OAuth, payment, and social-login popup flows, so it’s worth testing thoroughly before enabling it, and adding it only when you actually need the stronger isolation guarantees (for instance, SharedArrayBuffer usage) rather than as a default-on hardening step.

Step 2: writing a starting CSP

Once the easy headers are in, CSP is next — and it’s where teams get hurt if they rush. The core directives you’ll actually configure:

DirectiveControls
default-srcFallback for other fetch directives
script-srcScript sources
style-srcStylesheet sources
img-srcImage sources
font-srcFont sources
connect-srcFetch/XHR/WebSocket destinations
frame-srcIframe sources
report-uri / report-toViolation report destination

Concrete starting values for each of these directives are given in the policy below, which is an illustrative starting point to adapt to your own origins.

A reasonable starting policy for a Next.js app that calls its own API and a CMS, with no third-party embeds yet, looks like:

default-src 'self';
script-src 'self' 'nonce-{NONCE}';
style-src 'self' 'nonce-{NONCE}';
img-src 'self' data: https://your-cdn.example.com;
font-src 'self';
connect-src 'self' https://api.your-cms.example.com;
frame-src 'none';
form-action 'self';
report-uri /api/csp-report;

Two habits worth adopting up front: set up CSP as early in a project’s life as you can, because letting it pile up until launch week means diagnosing a wall of violations under deadline pressure instead of one at a time as features land; and start from the most restrictive value for each directive — form-action 'none' if you have no forms yet, only relaxing it to 'self' when a form actually ships — rather than starting broad and trying to tighten later.

connect-src deserves attention if you’re on a headless architecture: it generally needs to allow every API or CMS origin your client-side code calls, otherwise fetches can fail with a console violation rather than the kind of network error you’d normally debug. Worth testing against your actual API calls before relying on the default.

Nonce-based CSP in the App Router — and why it demands dynamic rendering

This is the part of the checklist that trips people up, because it’s genuinely coupled to rendering mode, not just header syntax.

A nonce is a random, unique, single-use token added to a CSP directive ('nonce-{value}') and mirrored onto specific <script>/<style> tags, letting the browser allow just those tags to execute while still blocking arbitrary inline script. It has to be unpredictable and regenerated on every request — a static or reused nonce is not meaningfully different from 'unsafe-inline'.

Next.js’s documented pattern for this uses middleware (Proxy) running ahead of rendering: middleware generates a fresh nonce, sets it into a custom header, builds the CSP header value with that nonce embedded, and Next.js’s rendering pipeline reads it back out during server rendering to attach the nonce to framework-injected scripts and any component using the nonce prop. Middleware, by default, runs on every request, but you’ll want a matcher that excludes prefetches and static assets that don’t need a CSP header at all, both for performance and to avoid nonce churn on requests that don’t render a page. If you haven’t set up middleware routing/matchers before, that’s worth getting right independently of CSP, since a misconfigured matcher is a common source of “why is this header missing on half my routes” bugs.

Here’s the catch, and it’s not optional: nonce-based CSP requires the page to be dynamically rendered. A nonce only exists because middleware generated one for this specific request — but statically generated pages are built once, ahead of any request, with no request or response headers in scope at build time, so there’s nothing for Next.js to inject a nonce into. Practically, that means adopting a nonce-based CSP on a route disables static optimization and ISR for that route — you’re trading the caching and CDN-serving benefits of static generation for the ability to enforce a strict, nonce-scoped script-src. For a marketing site that’s mostly static pages, that trade is often not worth it route-by-route; for an authenticated dashboard that’s already dynamic, it’s close to free.

Static-heavy routes: your options when nonces aren’t viable

If a meaningful chunk of your app is static-generated — marketing pages, docs, blog content — you have four realistic paths, and picking the wrong one is where CSP rollouts stall:

  1. Keep the routes static and use a non-nonce CSP model. Rely on 'self' plus explicit allowlisted origins for third-party scripts, skipping per-request nonces entirely on those routes. Less strict, but compatible with static generation and CDN caching.
  2. Make specific routes dynamic. Where the strict, nonce-scoped policy is worth losing static optimization for — checkout flows, auth pages — opt those routes into dynamic rendering deliberately rather than site-wide.
  3. Move inline scripts to external files. Anything currently inline that’s static content (not templated per-request) can usually be extracted into a separate .js file served from 'self', sidestepping the nonce question altogether.
  4. Use CSP hashes for static inline blocks. A hash-based directive ('sha256-...') allows a specific inline script by the hash of its exact content, computed at build time — which works for static pages precisely because it doesn’t depend on a per-request value the way a nonce does.

Related but distinct: Next.js has experimental Subresource Integrity support that attaches integrity hashes to build-time JavaScript assets, but as of this writing that support is limited to the App Router under webpack and doesn’t cover dynamically generated scripts. SRI and CSP hashes solve adjacent problems — SRI verifies a fetched asset hasn’t been tampered with, CSP hashes/nonces control what’s allowed to execute at all — and neither is a full substitute for the other, but on static-heavy routes, build-time hashing (via either mechanism) is the tool that fits the rendering model, where nonces don’t.

The unsafe-inline and unsafe-eval trap

Here’s the failure mode that explains why so many production CSPs quietly include 'unsafe-inline' despite knowing better.

'unsafe-eval' is required in development because React uses eval-like mechanisms for enhanced debugging — reconstructing server-side error stacks in the browser — but this is a development-only need; neither React nor Next.js requires it in production by default. That one’s easy: gate it behind process.env.NODE_ENV !== 'production' and move on.

'unsafe-inline' is the harder one. A GitHub discussion on the Next.js repository reports that removing 'unsafe-inline' from script-src breaks hydration in production, because Next.js automatically injects a number of inline scripts into the DOM during hydration that get blocked outright once 'unsafe-inline' is gone — while acknowledging that keeping it in leaves the app exposed to the exact inline-script XSS vector CSP exists to close. That’s a real tension, not a configuration mistake: it’s the framework’s own hydration bootstrapping colliding with the strict version of the policy. This is precisely why the nonce workflow above exists — nonces let Next.js’s framework-injected inline scripts execute (because they carry the matching nonce) while still blocking attacker-injected inline scripts (which don’t). If you’re not ready to do the dynamic-rendering nonce work, dropping 'unsafe-inline' blind is likely to produce a broken, unhydrated page rather than a more secure one — treat that trade-off explicitly rather than discovering it in production.

Third-party scripts and tag managers

Analytics, tag managers, and embedded widgets are usually the biggest source of CSP violations once you turn enforcement on, because each one wants its own script origin, its own connect-src targets for beacon calls, and sometimes inline configuration snippets that don’t fit a nonce model cleanly. The pragmatic approach: inventory every third-party origin a page actually loads from (open the Network tab, not the vendor’s install docs) before writing the allowlist, add each origin explicitly to the relevant directive rather than widening default-src, and treat any vendor that requires broad 'unsafe-inline' or arbitrary subdomain wildcards as a real cost to weigh against the feature. Vercel preview deployments are worth calling out specifically — if you use Vercel’s preview environment, its comment/feedback tooling injects its own scripts, which means your preview CSP typically needs an additional allowance that production doesn’t.

Report-only mode, and the self-DDoS story

Content-Security-Policy-Report-Only is the header that lets the browser evaluate your policy and report violations without actually blocking anything — it’s the safe way to find out what a strict policy would have broken before it does. A practical pattern is a single boolean flag — something like cspReportOnly — that toggles whether the header name is the enforcing or report-only variant, so you can flip it in one place once violations quiet down.

But report-only isn’t risk-free, and it’s worth knowing the specific way it can go wrong. One account describes enabling report-only mode without a sufficiently complete baseline policy, which flooded the CSP violation-reporting endpoint — over a hundred reports triggered by a single page visit — and the resulting spike in serverless-function invocations behaved like a self-inflicted denial of service, forcing a rollback of the report-only setup entirely. The lesson isn’t “don’t use report-only” — it’s “don’t point report-only at an unthrottled serverless endpoint before you’ve got a policy that’s at least roughly right,” and to treat the reporting endpoint itself as something that needs rate limiting or sampling before it goes live. Building that endpoint is a small project of its own — the same care you’d put into any webhook receiver that has to survive an unpredictable burst of inbound requests applies here.

One more environment-specific gotcha: upgrade-insecure-requests should generally be left out of development policies, since dev servers commonly run plain HTTP, and browsers reportedly ignore the directive entirely when it’s delivered via a report-only header — producing a console warning rather than any effect.

Testing before you flip the switch

Before enforcing anything in production:

  • Watch the browser console. CSP violations log there in real time, with the exact directive and blocked URI — this is your fastest feedback loop during local testing.
  • Use the Network tab to see what a page actually requests, so your allowlist reflects reality rather than vendor documentation.
  • Run an external scanner like SecurityHeaders.com against a staging or preview URL to see your header posture holistically, the same tool that produced the F-to-A story above.
  • Run a CSP-specific linter against your policy string to catch overly permissive or contradictory directives before they ship.
  • Test report-only in staging first, with the reporting endpoint rate-limited or sampled, before pointing it at production traffic.

Rollout order, end to end

Put together, the sequence looks like this: ship the non-CSP headers first since they’re low-risk and high-yield; add HSTS only once HTTPS is solid everywhere it applies; write a restrictive starting CSP with explicit directives per resource type; decide route-by-route whether nonces (dynamic), hashes (static), or a broader allowance fits each page’s rendering mode; run it in report-only with a rate-limited endpoint before enforcing; and keep NODE_ENV-gated differences for development, preview, and production rather than one policy for all three. None of this replaces validating input or encoding output — it’s the layer that limits what happens when something else already went wrong.

FAQ

Does CSP replace the need for input validation or output encoding? No. CSP is enforced by the browser and controls what can load or execute; it doesn’t fix an XSS vulnerability in your code, it limits what an attacker can do if one exists.

Can I use nonces on static pages? Not directly — nonces require a per-request value generated at render time, which static generation doesn’t have. Use hashes, external script files, or make the specific route dynamic instead.

Why does removing unsafe-inline break my app after I add nonces correctly? If middleware isn’t generating a nonce, isn’t matching the route, or the route is statically rendered, Next.js has nothing to attach to its own inline scripts, and they’ll be blocked the same as an attacker’s would be.

Is report-only mode safe to leave on indefinitely? It’s safe for the browser experience, but not necessarily for your infrastructure — an unthrottled reporting endpoint can be overwhelmed by violation reports from a permissive-but-not-yet-tuned policy.

Do I need Cross-Origin-Opener-Policy on every app? No — it’s a stronger isolation control worth adding when you specifically need it (e.g. SharedArrayBuffer), and it’s known to interfere with OAuth, payment, and social-login popups if applied broadly without testing.