Type something to search...
Setting a Content Security Policy (CSP)

Setting a Content Security Policy (CSP)

Cross-site scripting is one of those vulnerabilities that sounds theoretical right up until it isn't. All it takes is one comment form, one rich-text field, or one compromised third-party script tag, and an attacker can run arbitrary JavaScript in your users' browsers under your domain's name. A Content Security Policy is the browser-level safety net that stops that script from running even after it's been injected. It's not a replacement for sanitizing input or escaping output, but it's the layer that catches what your other defenses miss.

Next.js doesn't ship a CSP by default, and it can't, because a policy that's too strict for your app will silently break your own scripts and styles. Setting one up is entirely your responsibility, and the App Router gives you two fundamentally different ways to do it depending on whether you're willing to trade static rendering for stricter security. This article walks through both paths, what each one costs you in practice, and the mistakes that turn a working CSP into a debugging afternoon.

What CSP Actually Controls

A Content Security Policy is an HTTP response header (or an equivalent <meta> tag) that tells the browser which sources are allowed to load which kinds of content on your page. You can restrict scripts to your own domain, block inline <script> tags outright, forbid images from anywhere except your CDN, or prevent your site from being embedded in an iframe on someone else's domain. The header is a single string built from a list of directives, each one scoping a category of resource:

default-src 'self';
script-src 'self' 'nonce-abc123' 'strict-dynamic';
style-src 'self' 'nonce-abc123';
img-src 'self' blob: data:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;

Read that as a set of allowlists. default-src 'self' says "unless overridden below, only load resources from my own origin." script-src then narrows scripts specifically, object-src 'none' bans Flash/plugin-style embeds entirely, and frame-ancestors 'none' stops your site from being iframed by anyone (the modern replacement for the old X-Frame-Options header). The browser enforces every directive on every page load — if a script tries to load from a domain not on the list, it's blocked, full stop, before it ever executes.

The hard part isn't understanding the syntax. It's that a strict CSP and inline <script> tags are fundamentally at odds. By default, script-src 'self' blocks every inline script and every inline event handler on the page, and React (and Next.js's own hydration bootstrap) leans on inline scripts to get your app running. That's where nonces come in.

Nonces: The Bridge Between Strict CSP and Inline Scripts

A nonce ("number used once") is a random, unguessable string generated fresh for every single request. You add it to your CSP header as 'nonce-<value>', and then tag your inline scripts with the matching nonce attribute. The browser will only execute an inline script if its nonce matches the one in the CSP header for that exact response. Since the value changes every request, an attacker who manages to inject a <script> tag into your HTML has no way to know what nonce to put on it — they'd have to guess a fresh random string every time, which defeats the entire point of the attack.

This is the mechanism Next.js's own docs point you toward, and it's implemented through Proxy — the file convention that runs before a request reaches your route (this replaced what used to be called Middleware in earlier Next.js versions; if you've worked with older projects, proxy.ts is the direct successor to middleware.ts).

// proxy.ts
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();

  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;
}

There's a lot packed into those few lines, so let's unpack the parts that aren't obvious from reading it once.

The nonce gets set twice. Once on the outgoing request headers (requestHeaders), and once on the response headers. The request-header copy is what lets your Server Components read the nonce back out later via headers() — proxy runs before rendering, and this is the mechanism for passing a value forward into the render. The response-header copy is what the browser actually enforces the policy against.

'strict-dynamic' matters more than it looks. Without it, every single script your bundler emits — every chunk, every dynamically imported module — would need its own nonce or its own explicit allowlist entry. strict-dynamic tells the browser "trust any script that a nonce-verified script loads," which is what makes strict CSP survive contact with a real bundler that splits your app into dozens of chunks at build time. Drop strict-dynamic and you'll spend your afternoon manually chasing down every blocked chunk in the console.

'unsafe-eval' in development is not a mistake, it's necessary. React uses eval in development to reconstruct readable stack traces for server-side errors in the browser console. If you strip 'unsafe-eval' out of your dev policy trying to be extra strict, you'll get baffling CSP violations on every error boundary trigger, with no indication that the debugging tooling itself is the culprit. Production doesn't need it — neither React nor Next.js uses eval outside of development.

Wiring the Matcher So You're Not Doing This on Every Request

By default, Proxy runs on every single request that hits your app, including the internal prefetch requests Next.js's <Link> component fires when a link scrolls into view. Regenerating a CSP header and computing a fresh nonce for those prefetches is wasted work, and in some setups it can actually break prefetching since the prefetch response won't be rendered as a full page. The docs recommend scoping Proxy with a matcher that skips static assets and prefetch requests specifically:

// proxy.ts
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" },
      ],
    },
  ],
};

That missing clause is the part people skip and then wonder why prefetching feels sluggish. It says "match this route, but only if the request does not carry a prefetch header" — meaning full navigations get the CSP treatment, and prefetches skip past Proxy's CSP logic entirely.

How Next.js Actually Applies the Nonce for You

Here's the part of this system that's genuinely clever and worth understanding rather than just copy-pasting: you don't have to manually stamp a nonce attribute onto every script tag Next.js generates internally. During server-side rendering, Next.js reads the Content-Security-Policy response header itself, extracts the nonce with a 'nonce-{value}' pattern match, and automatically attaches it to:

  • the React and Next.js runtime bootstrap scripts
  • your page's own JavaScript bundle
  • inline styles and scripts the framework generates
  • any <Script> component that has strategy set and doesn't already have an explicit nonce prop

This only works for dynamically rendered pages, because the whole system depends on there being an actual request with an actual header at render time — during static generation there's no request in flight, so there's nothing for Next.js to read a nonce out of. That single fact cascades into the biggest tradeoff of this entire approach.

The Tradeoff Nobody Skips Past For Free: Dynamic Rendering

If you add nonce-based CSP to your app, every page that needs it must be dynamically rendered. Not "should be" — must be, or the nonce mechanism has nothing to attach to. Concretely, this means:

  • Static optimization and Incremental Static Regeneration are off the table for those routes.
  • CDNs can't cache the response without extra work, since every response carries a unique nonce and is, by definition, different from the last one.
  • Partial Prerendering is flatly incompatible with nonce-based CSP — a PPR static shell is generated once at build time and served to every visitor, but a nonce has to be unique per request, so there's no way to bake a matching nonce into a shell that's reused across requests.
  • Every request now does full server-side rendering work instead of serving a cached response, which means more CPU on your server and a slower time-to-first-byte for visitors.

If you're building something like a banking dashboard or an admin panel where the pages were never going to be static anyway, this costs you nothing you weren't already paying. If you're building a marketing site or a blog and you bolt strict nonce-based CSP onto every route "just to be safe," you'll quietly disable ISR and PPR across your whole app and wonder later why your Lighthouse scores tanked. Scope nonces to the routes that actually need them — an authenticated dashboard, a payment flow — and leave your static marketing pages alone.

To force a page into dynamic rendering explicitly (rather than relying on it happening implicitly because you read a dynamic API), call connection():

// app/page.tsx
import { connection } from "next/server";

export default async function Page() {
  await connection();
  // Your page content
}

And to actually read the nonce back out inside a Server Component so you can pass it to something like a <Script> tag:

// app/page.tsx
import { headers } from "next/headers";
import Script from "next/script";

export default async function Page() {
  const nonce = (await headers()).get("x-nonce");

  return (
    <Script
      src="https://www.googletagmanager.com/gtag/js"
      strategy="afterInteractive"
      nonce={nonce}
    />
  );
}

The Simpler Path: No Nonces, Static Header in next.config.js

If your app doesn't have strict security requirements — no compliance mandate, no handling of sensitive financial or health data — you can skip the entire nonce dance and set a CSP directly in next.config.js as a static header. This keeps every page statically optimizable, because the header value never changes between requests.

// next.config.js
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",
            value: cspHeader.replace(/\n/g, ""),
          },
        ],
      },
    ];
  },
};

Notice the tradeoff hiding in plain sight: this version has to include 'unsafe-inline' on script-src and style-src, because without a nonce mechanism there's no way to selectively permit specific inline scripts — you either allow all inline execution or block it entirely, and blocking it entirely breaks the framework's own bootstrap. 'unsafe-inline' significantly weakens the protection CSP is supposed to provide against injected scripts, since an attacker's injected <script> tag is just as "inline" as your legitimate ones and gets waved through by the same rule.

This is the right default for most apps: real protection against loading scripts from unauthorized external origins, full compatibility with static generation and ISR, and an honest acknowledgment that you're not defending against inline script injection specifically. If you later discover you actually need that stronger guarantee, that's the signal to move to nonces for the specific routes that need it, not to weaken your default policy globally.

Subresource Integrity: A Third Option Worth Knowing About

Next.js also has experimental support for Subresource Integrity as an alternative to nonces, and it's worth knowing this exists even though it's currently marked experimental and limited to the App Router. SRI works by hashing your JavaScript files at build time and attaching the hash as an integrity attribute on the script tag. The browser then verifies, before executing anything, that the file it downloaded matches the hash — if a CDN got compromised or a build artifact was tampered with in transit, the hash won't match and the browser refuses to run it.

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    sri: {
      algorithm: "sha256", // or 'sha384' or 'sha512'
    },
  },
};

module.exports = nextConfig;

The appeal here is that SRI is a build-time mechanism, not a per-request one, so it doesn't force dynamic rendering the way nonces do — your pages stay statically generated, cacheable at the CDN, and eligible for ISR. The catch is what SRI is actually protecting against: it verifies file integrity (this exact file wasn't tampered with), not source authorization (this origin is allowed to serve scripts at all). It's a genuinely different guarantee from CSP's origin-allowlisting, and in practice it's most useful combined with a CSP rather than instead of one. Given its experimental status, I'd treat it as something to watch rather than something to build a production security model around today — and it explicitly doesn't work if you need to handle dynamically generated scripts, since there's no file to hash at build time for those.

Debugging CSP Violations Without Losing an Afternoon

Every CSP mistake shows up the same way: something on your page silently doesn't work, and the browser console has a line starting with "Refused to execute inline script because it violates the following Content Security Policy directive." That message is actually specific and useful — it names the exact directive that blocked the resource — but it's easy to skim past when you're staring at a broken widget instead of an open DevTools console.

A short list of the violations that come up constantly in practice:

Inline style attributes and <style> blocks. A lot of UI libraries inject styles directly via style={{ ... }} compiled to inline attributes, or via runtime-injected <style> tags (classic CSS-in-JS behavior). Under a strict style-src without 'unsafe-inline', these get blocked unless the library specifically supports nonces — check the library's docs for Next.js/CSP guidance before you spend an hour debugging what looks like a missing stylesheet.

Third-party scripts that don't know about your nonce. Google Tag Manager, analytics snippets, chat widgets — anything you drop in as a raw script tag needs the nonce threaded through explicitly, and needs its origin added to script-src. This is exactly why the @next/third-parties package's components (like GoogleTagManager) accept a nonce prop directly instead of making you fight with raw <script> tags:

// app/layout.tsx
import { GoogleTagManager } from "@next/third-parties/google";
import { headers } from "next/headers";

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const nonce = (await headers()).get("x-nonce");

  return (
    <html lang="en">
      <body>
        {children}
        <GoogleTagManager gtmId="GTM-XYZ" nonce={nonce} />
      </body>
    </html>
  );
}

And the corresponding CSP needs the third party's actual domains added, not just your own:

script-src 'self' 'nonce-abc123' 'strict-dynamic' https://www.googletagmanager.com;
connect-src 'self' https://www.google-analytics.com;
img-src 'self' data: https://www.google-analytics.com;

WebAssembly. If anything in your dependency tree compiles to Wasm (some image processing libraries, some crypto libraries), you need 'wasm-unsafe-eval' in script-src or every Wasm module load gets blocked with an error that gives you no hint it's Wasm-related.

Service workers. If you're running a PWA with an offline service worker alongside a strict CSP, the service worker script and everything it fetches needs its own consideration — it's a separate execution context from the page and CSP applies to it independently.

Rolling Out a New Policy Without Breaking Production

Here's a technique the Next.js docs don't mention but that will save you from shipping a CSP that silently breaks half your app's third-party integrations: the Content-Security-Policy-Report-Only header. It's a second header, distinct from Content-Security-Policy, that tells the browser to evaluate your policy and report every violation it would have blocked, without actually blocking anything. Nothing on your page breaks, but you get a live feed of exactly what your real policy would need to allow.

// next.config.js — testing a new policy before enforcing it
module.exports = {
  async headers() {
    return [
      {
        source: "/(.*)",
        headers: [
          {
            key: "Content-Security-Policy-Report-Only",
            value: cspHeader.replace(/\n/g, ""),
          },
        ],
      },
    ];
  },
};

Pair it with a report-to or the older report-uri directive pointing at an endpoint you control (a simple Route Handler that logs the JSON payload the browser POSTs is enough to start with), and run it in production for a few days before you ever set the enforcing header. This matters more than it sounds like it should, because your local dev environment almost never exercises every code path a real user hits — a payment modal that only renders after a specific interaction, a third-party widget that only loads for logged-in users, a feature flag that's off for you but on for 5% of production traffic. Every one of those can carry its own inline script or its own external origin that your locally-tested policy never saw. Report-only mode surfaces them from real traffic before they become a support ticket.

A minimal collection endpoint looks like this:

// app/api/csp-report/route.ts
import { NextRequest, NextResponse } from "next/server";

export async function POST(request: NextRequest) {
  const report = await request.json();
  console.error("CSP violation:", JSON.stringify(report, null, 2));
  return NextResponse.json({ received: true }, { status: 200 });
}

Once the reports stop coming in for a representative stretch of real traffic, swap Content-Security-Policy-Report-Only for the enforcing Content-Security-Policy header and keep the reporting endpoint wired up indefinitely — new violations after that point usually mean a new dependency shipped an inline script, or a teammate added a widget without checking the policy first.

CSP and Next.js's Own Image and Font Handling

One gap that trips people up specifically in a Next.js context: next/image can rewrite image URLs to route through Next.js's own image optimization endpoint (/_next/image), and if your img-src directive is scoped too narrowly, optimized images silently fail to load with a CSP violation rather than a 404. Since the optimization endpoint is served from your own origin, img-src 'self' covers it — the trap is people who explicitly enumerate allowed image hosts (for a headless CMS, say) and forget that 'self' still needs to stay in the list for Next.js's own optimized output:

img-src 'self' https://your-cms-domain.com blob: data:;

The same applies to next/font when you're self-hosting Google Fonts — because Next.js downloads and serves the font files from your own domain at build time rather than linking out to fonts.gstatic.com, font-src 'self' is all you need, and you can actually remove any fonts.googleapis.com / fonts.gstatic.com entries you might have copied from a pre-Next.js CSP template. Leaving them in isn't harmful, just dead weight signaling your policy wasn't written for how this framework actually serves fonts.

A Practical Recommendation

Don't reach for nonces by default. Start with the static next.config.js header approach, accept 'unsafe-inline' as a known, deliberate tradeoff, and get real protection against unauthorized external origins with zero rendering cost. Move to nonces only for the specific routes where you have an actual compliance requirement or a specific threat model that calls for it — a login flow, a payment page, an admin dashboard handling other people's data. Treat SRI as something to evaluate later once it graduates from experimental, not something to build around today. And whatever you choose, test it in a production build (npm run build && npm run start) before you ship, since development mode's 'unsafe-eval' requirement can mask violations that only show up once that flag is gone.

Key Takeaways

ApproachStatic rendering?Protects againstSetup cost
No CSPYesNothingNone (and that's the problem)
Static header (next.config.js)YesUnauthorized external originsLow
Nonce-based CSP (Proxy)No — forces dynamic renderingExternal origins + inline script/style injectionHigh
Subresource Integrity (experimental)YesTampered build artifactsMedium, App Router only

A Content Security Policy is one of the few security controls where the browser does the enforcement for you — you just have to describe the rules correctly and understand what you're trading away to make them strict. Get the static header in place on every project as a baseline, and reserve the nonce-based approach for the routes where dynamic rendering was already the plan.

Tags :
Share :

Related Posts

Can Next.js Be Used with GraphQL?

Can Next.js Be Used with GraphQL?

Next.js and GraphQL are two powerful technologies that have gained significant traction in the web development community. Next.js, a React-based fram

Dive Deeper
How does Next.js differ from Create React App?

How does Next.js differ from Create React App?

In the world of modern web development, React.js has emerged as a dominant force due to its flexibility, performance, and extensive ecosystem. Two po

Dive Deeper
How does Next.js handle image optimization?

How does Next.js handle image optimization?

In modern web development, image optimization plays a critical role in enhancing user experience and improving site performance. Large, unoptimized i

Dive Deeper