Adding security headers to a Next.js site in 5 minutes

If you have ever run an audit on a brand-new Next.js site, you have seen the report: Missing Content Security Policy. Missing HSTS. Missing X-Frame-Options. The default Next.js setup ships with none of these. The good news is you fix all six in a single next.config.js change, with no extra packages or middleware. Here is the copy-paste version.

What we are adding and why

Modern browsers respect a set of response headers that tell the browser how to treat your page. Setting them correctly closes off entire categories of attacks. Not setting them leaves the door open for the lazy ones.

Content-Security-Policy (CSP)
The big one. Defines which sources scripts, styles, images, and fonts can load from. Prevents XSS attacks by blocking inline or third-party code that did not come from your allow-list.
Strict-Transport-Security (HSTS)
Tells the browser to use HTTPS for your domain for a long time (we use 2 years). Even if a user types http:// or clicks a downgraded link, the browser refuses to make the HTTP request.
X-Frame-Options
Prevents your site from being embedded in an iframe on another domain. Stops clickjacking attacks where an attacker overlays your UI inside a malicious page.
X-Content-Type-Options
Single-value header (always nosniff). Prevents browsers from guessing the MIME type of a response — closes a category of attacks where a file is delivered as one type but executed as another.
Referrer-Policy
Controls what URL information is sent in the Referer header when a user clicks a link out of your site. Limits how much you leak to third parties about user navigation.
Permissions-Policy
Disables browser features your site does not need — camera, microphone, geolocation, FLoC (Google's interest-based ad cohort). If you do not use them, turn them off.

The full next.config.js

Open next.config.js at the root of your Next.js project. If it does not exist, create it. Here is the complete file:

/** @type {import('next').NextConfig} */
const nextConfig = {
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [
          {
            key: 'Content-Security-Policy',
            value: [
              "default-src 'self'",
              "script-src 'self' 'unsafe-inline'",
              "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
              "img-src 'self' data: https:",
              "font-src 'self' https://fonts.gstatic.com data:",
              "connect-src 'self'",
              "frame-src 'self'",
              "object-src 'none'",
              "base-uri 'self'",
              "form-action 'self'",
              "frame-ancestors 'self'",
              "upgrade-insecure-requests",
            ].join('; '),
          },
          { key: 'X-Frame-Options', value: 'SAMEORIGIN' },
          { key: 'X-Content-Type-Options', value: 'nosniff' },
          { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
          {
            key: 'Permissions-Policy',
            value: 'camera=(), microphone=(), geolocation=(), interest-cohort=()',
          },
          {
            key: 'Strict-Transport-Security',
            value: 'max-age=63072000; includeSubDomains; preload',
          },
        ],
      },
    ]
  },
}

module.exports = nextConfig

Save, commit, deploy. Six headers shipped at once. That is the whole job for a site that does not use external scripts, fonts, or API calls beyond your own backend.

Customizing CSP for your stack

The Content Security Policy is the one header where you almost certainly need to add domains. The CSP above is restrictive — it allows your own origin, Google Fonts, and inline styles (which Next.js requires for streaming HTML). If you use any third-party service, you need to add it.

Stripe

For Stripe Checkout, you need to allow their scripts and iframes:

"script-src 'self' 'unsafe-inline' https://js.stripe.com",
"frame-src 'self' https://js.stripe.com https://hooks.stripe.com",
"connect-src 'self' https://api.stripe.com",

Supabase

If you use Supabase Auth or Storage, allow their domain on connect-src:

"connect-src 'self' https://*.supabase.co",

Cloudflare Turnstile

Turnstile injects a script and renders inside an iframe:

"script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com",
"frame-src 'self' https://challenges.cloudflare.com",

Your own backend

If your frontend calls a separate backend (Railway, Fly, Render, whatever), add that domain to connect-src:

"connect-src 'self' https://api.yourdomain.com",

Testing CSP without breaking the site

CSP errors are silent in production. The page renders, but a script that violated the policy was blocked, and your app silently fails. To catch this before users do, use Content-Security-Policy-Report-Only mode first.

Change the header key for a few days:

{ key: 'Content-Security-Policy-Report-Only', value: '...' }

In report-only mode, the browser logs violations to the console but does not block anything. You ship to production, you open Browser DevTools, you check the console for Refused to load... messages. Each one is a domain you need to add to your policy.

Once the console is clean for a week of normal traffic, flip the header key back to Content-Security-Policy (without -Report-Only) to enforce it.

The HSTS gotcha

HSTS is powerful and irreversible. Once a browser sees the header, it will refuse to make HTTP requests to your domain for the duration of max-age — even if you later remove the header. If you set max-age=63072000 (2 years) and then break HTTPS for some reason, every browser that visited you will refuse to connect for two years.

Recommended approach:

  1. Start with a short max-age like max-age=86400 (1 day) to test
  2. Verify HTTPS works perfectly across all subdomains and assets
  3. Bump to max-age=31536000 (1 year)
  4. Bump to max-age=63072000 (2 years) and add preload if you want browser-baked-in HSTS

The includeSubDomains directive is also worth thinking about. If you have a subdomain that genuinely cannot be served over HTTPS (a legacy admin panel, say), do not set includeSubDomains until you have fixed that subdomain.

What to skip

Some headers exist but are not worth setting in 2026:

If a checker tool tells you that you are missing any of the above, ignore it — the checker is out of date.

Vercel-specific note

On Vercel, the headers() function in next.config.js applies to all routes, including static assets served from /public and all dynamic routes. No additional config needed.

One thing to verify: Vercel adds an X-Vercel-Cache header automatically, and your CSP does not need to allow it — your own headers are layered on top, not replaced.

Verifying it works

After deploying, three ways to check:

Option 1: Lintry

Run a scan on your URL. Lintry reports every missing or misconfigured security header with the recommended fix. Try it free.

Option 2: Browser DevTools

Open DevTools, go to Network tab, click on the first document request. Look at Response Headers. All six headers should be present.

Option 3: curl

curl -I https://yourdomain.com | grep -iE "csp|content-security|strict-transport|x-frame|x-content|referrer|permissions"

You should see all six headers in the response.

That is it

Six headers, one config file, one deploy. The fix takes longer to read about than to implement. Most Next.js sites in production are missing at least three of these. Yours does not have to be.

"CSP errors are silent in production. The page renders, but a script that violated the policy was blocked, and your app silently fails."

If you want the audit version of "did I do this right" — run Lintry against your domain. The whole point of having a website QA tool is to not have to manually verify every header on every deploy. Set it once, run scans on every release, and stop worrying about regressions.

Audit your security headers

Get a full report on missing or misconfigured headers in 60 seconds.

Run a free scan →