MGMeghraj Giri

Security Headers for a Next.js Site: What I Shipped, and Why script-src Isn't One of Them

The security headers this Next.js site sends, what each one prevents, and why a strict Content-Security-Policy script-src was left out on purpose.

An SEO audit of this site flagged something I should have fixed long before: it sent no security headers at all. There was no X-Content-Type-Options, no Referrer-Policy and no Content-Security-Policy, and it advertised x-powered-by: Next.js to anyone who asked.

Adding headers in Next.js takes about twenty lines. Choosing which ones took longer, because the most powerful header, a strict Content-Security-Policy, is also the one most likely to break a working site. Here is what I shipped, what each header does, and the one I deliberately left out.

Where headers go in Next.js

For headers that apply to every route, the simplest place is the headers() function in next.config.ts:

// next.config.ts
const nextConfig = {
  poweredByHeader: false,

  async headers() {
    return [
      {
        source: "/:path*",
        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: "Content-Security-Policy",
            value: [
              "base-uri 'self'",
              "form-action 'self'",
              "object-src 'none'",
              "frame-ancestors 'none'",
              "upgrade-insecure-requests",
            ].join("; "),
          },
        ],
      },
    ];
  },
};

You can check the result on any deployed site with curl -I https://your-site.com.

What each one does

`poweredByHeader: false` removes x-powered-by: Next.js. It is not a defence, but it stops handing out a free hint to anyone scanning for sites running a framework with a known issue.

`X-Content-Type-Options: nosniff` tells the browser to trust the Content-Type the server declared instead of guessing from the content. Without it, a file served as plain text can sometimes be interpreted as a script, which is how an uploaded file becomes an attack.

`X-Frame-Options: DENY` stops any other site from loading yours in an iframe. That blocks clickjacking, where your page is shown invisibly under someone else's UI so that a visitor's clicks land on your buttons. A contact form is a plausible target.

`Referrer-Policy: strict-origin-when-cross-origin` controls what your site reveals when a visitor follows a link elsewhere. Within your own site the full URL is sent. To other sites only the origin is sent, so paths and query strings stay private. Nothing at all is sent when moving from HTTPS to HTTP.

`Permissions-Policy` declares which powerful browser features the page may use. A portfolio needs no camera, microphone or location, so they are all switched off, for the page and anything embedded in it.

The Content-Security-Policy I did ship

A CSP is a list of directives, and you do not need all of them. These four cost nothing on a normal site and each closes a specific hole:

  • `base-uri 'self'` — stops an injected <base> tag from silently redirecting every relative URL on the page to another domain.
  • `form-action 'self'` — stops an injected form, or a tampered one, from posting your visitors' input to someone else's server.
  • `object-src 'none'` — disables legacy plugin embeds such as <object> and <embed>, which no modern site needs.
  • `frame-ancestors 'none'` — the modern version of X-Frame-Options: DENY. Sending both covers older browsers too.

upgrade-insecure-requests asks the browser to fetch any stray http:// resource over HTTPS instead.

Why there is no script-src

The directive people mean when they talk about CSP is script-src: an allow-list of which scripts may run. Set strictly, it is the strongest defence the web has against cross-site scripting. So why leave it out?

Because Next.js renders inline scripts. The framework inlines its bootstrap code and the RSC payload into every page, and this site adds inline JSON-LD for structured data. A strict script-src blocks inline scripts unless each one is allowed, which leaves three options:

1. Allow `'unsafe-inline'`. Everything works, but you have allowed exactly what most XSS attacks inject: an inline script. The policy is mostly decoration at that point.

2. Use a nonce. The server generates a random value for each request, puts it in the CSP header, and adds it to every legitimate script tag. Next.js supports this through middleware. The catch is in the words "each request": a page whose HTML contains a per-request value cannot be prerendered, so every page becomes dynamically rendered. For a content site that is otherwise fully static, giving up static pages, and the fast time-to-first-byte that comes with them, is a steep price.

3. Use hashes. You can allow specific inline scripts by their hash, but Next.js's inline scripts change with every build and every page's content. Keeping a hash list in sync by hand is not realistic. Next.js has an experimental Subresource Integrity option aimed at part of this problem. I have not adopted it yet.

For a static site with no user-generated content, I decided a policy that only allows inline script was not worth the cost of a nonce. That is a trade-off, not a best practice, and it would go the other way for an app with logins, user input or payments. There, take the nonce and the dynamic rendering.

Third-party scripts make it harder

This blog now runs Google AdSense, which is a good example of why script-src gets complicated. An ad script loads further scripts and iframes from a long list of Google domains that can change over time. A strict policy has to allow all of them, and an incomplete one does not fail loudly: the ads simply stop appearing, in production, with only a console message to show for it.

One thing that did not need changing: X-Frame-Options and frame-ancestors control whether your page may be framed by others. They have no effect on iframes your page embeds, such as ads or a YouTube video. Those are governed by frame-src, which this policy does not set.

Start with the headers that can't break anything

If your Next.js site sends no security headers today, the five above plus the partial CSP are a safe afternoon's work. None of them can break a normal page. Deploy, check them with curl -I, and you have closed clickjacking, MIME sniffing, referrer leakage and a few injection tricks.

Then treat script-src as its own project. Turn it on in report-only mode with Content-Security-Policy-Report-Only, watch what it would have blocked, and decide with real data whether a nonce is worth what it costs your site.