LEAKCADE

Security headers Next.js apps need: a copy-paste setup

Updated 8 min read

Security headers Next.js apps should send fit in one config block: Content-Security-Policy, Strict-Transport-Security, X-Content-Type-Options, Referrer-Policy, a framing rule and Permissions-Policy. Add them in next.config.js, ship the CSP in report-only mode first, and check the result with curl. They can limit the damage from bugs like XSS once the CSP is enforced and drops 'unsafe-inline'. Report-only mode protects nothing yet. They won't fix broken auth.

QUICK CHECKLIST

  • Set nosniff, Referrer-Policy and Permissions-Policy in next.config.js
  • Send HSTS, but skip preload until you're sure
  • Ship Content-Security-Policy-Report-Only first
  • Set frame-ancestors 'none' unless you need embedding
  • Check with curl -I and securityheaders.com
  • Don't count headers as a fix for auth bugs

What security headers Next.js apps should send

Security headers are instructions your server sends with every response. The browser reads them and switches on protections it would otherwise skip. In Next.js you set most of them in one config file.

Six are worth setting on a small app:

What it doesStarter value
Content-Security-PolicyLimits what the page may loadStart as Report-Only
Strict-Transport-SecurityForces HTTPS after the first secure visitmax-age=63072000; includeSubDomains
X-Content-Type-OptionsStops file type guessingnosniff
Referrer-PolicyControls the Referer headerstrict-origin-when-cross-origin
X-Frame-Options or frame-ancestorsBlocks other sites from framing youDENY or 'none'
Permissions-PolicyTurns off unused browser featurescamera=(), microphone=()

Starter values for a small app. Loosen them only when you need to.

Content-Security-Policy does the most work. It tells the browser which origins may load scripts, styles, images and frames. MDN describes it as defense in depth against cross-site scripting: it limits what injected code can do when your own input handling fails.

The other five are small. Strict-Transport-Security tells the browser to use HTTPS for your domain. The framing rule and Permissions-Policy cover clickjacking and browser features you never use.

What a CSP can stopAn injected script on your page tries to send data to an attacker's server. If the CSP is enforced and the origin is not allowed, the browser can block the request. In report-only mode nothing is blocked.INJECTEDScript slips intoyour pageBROWSERChecks yourenforced CSPREQUESTCall toevil.exampleBLOCKEDOrigin notallowed, so nocallINJECTEDScript slips into your pageBROWSERChecks your enforced CSPREQUESTCall to evil.exampleBLOCKEDOrigin not allowed, so no call
An enforced CSP without 'unsafe-inline' can block many of these calls. Report-only mode blocks nothing, and a CSP never removes the bug.

How attackers use missing headers

Each missing header leaves a specific hole.

A CSP doesn't replace fixing the bug. If your app renders user content, read XSS in React. Tokens in localStorage are exactly what injected script can read, which is covered in JWT in localStorage vs cookies.

For auth, start with Next.js middleware authentication and IDOR in API routes. Fix those before you spend an afternoon on a CSP.

Before and after: a typical AI-generated headers config

AI tools often paste old header advice. The config below has three problems. OWASP says to leave X-XSS-Protection out or set it to 0, because the old browser filter could create XSS holes in otherwise safe sites. Expect-CT is obsolete. And the CSP allows any host plus inline and eval code, so it blocks almost nothing.

BEFORETypical AI-generated config
module.exports = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: [
          { key: 'X-XSS-Protection', value: '1; mode=block' },
          { key: 'Expect-CT', value: 'max-age=86400, enforce' },
          { key: 'Content-Security-Policy', value: "default-src * 'unsafe-inline' 'unsafe-eval'" },
        ],
      },
    ]
  },
}
AFTERSafer starter config
const isDev = process.env.NODE_ENV === 'development'

const csp = [
  "default-src 'self'",
  `script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ''}`,
  "style-src 'self' 'unsafe-inline'",
  "img-src 'self' blob: data:",
  "object-src 'none'; base-uri 'self'; frame-ancestors 'none'",
].join('; ')

module.exports = {
  poweredByHeader: false,
  async headers() {
    return [{ source: '/(.*)', headers: [
      { key: 'Content-Security-Policy-Report-Only', value: csp },
      { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains' },
      { key: 'X-Content-Type-Options', value: 'nosniff' },
      { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
      { key: 'X-Frame-Options', value: 'DENY' },
      { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
    ] }]
  },
}
The left config uses obsolete headers and a CSP that allows everything. The right one sets six headers and starts the CSP in report-only mode.

The fix sets the six headers. The CSP goes out in report-only mode, so the browser logs violations to the console and blocks nothing while you learn what your app loads. 'unsafe-eval' is for development only: the Next.js docs say React needs it there and production doesn't. poweredByHeader: false drops the X-Powered-By header Next.js sends by default.

The framing rule uses X-Frame-Options here because the CSP isn't enforcing yet. Once you enforce the CSP, its frame-ancestors directive covers framing too.

The starter CSP still allows 'unsafe-inline', which MDN says to avoid because inline script is a common XSS vector. Treat it as step one. Keep a single /(.*) entry in headers() so you never have to wonder how two entries merge. The config leaves Access-Control-Allow-Origin alone, since that header belongs to CORS misconfiguration.

A CSP nonce in Next.js: when it's worth it

A nonce is a random value generated for every request. It goes into the CSP header and onto each script you trust. The browser runs a script only if the values match. MDN says the nonce must be unique per response and unpredictable.

The Next.js docs generate it in proxy.ts. On older versions the file is middleware.ts and the function is called middleware, so check the docs for your version. Send it as report-only first.

// proxy.ts (middleware.ts on older Next.js, function named middleware)
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 csp = [
    "default-src 'self'",
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${isDev ? " 'unsafe-eval'" : ''}`,
    `style-src 'self' ${isDev ? "'unsafe-inline'" : `'nonce-${nonce}'`}`,
    "img-src 'self' blob: data:",
    "object-src 'none'; base-uri 'self'; frame-ancestors 'none'",
  ].join('; ')

  const requestHeaders = new Headers(request.headers)
  requestHeaders.set('x-nonce', nonce)
  requestHeaders.set('Content-Security-Policy', csp)
  const response = NextResponse.next({ request: { headers: requestHeaders } })
  response.headers.set('Content-Security-Policy-Report-Only', csp) // rename to enforce
  return response
}

export const config = {
  matcher: '/((?!api|_next/static|_next/image|favicon.ico).*)',
}

Next.js reads the nonce from the CSP request header and adds it to its own scripts. The docs also say a nonce-based CSP needs pages rendered on each request, which affects static pages and caching. Third-party scripts need the nonce attribute too, and the docs show how to skip prefetch requests in the matcher, so check them before you switch.

If you use this file, delete the CSP entry from headers() in next.config.js. Two enforced policies on one response both apply, and the stricter result can break pages in confusing ways.

How to fix security headers in Next.js, step by step

Do these in order. The order keeps your app from breaking in front of users.

  1. Add the plain headers

    Put nosniff, Referrer-Policy, Strict-Transport-Security, X-Frame-Options and Permissions-Policy into one headers() entry for /(.*) in next.config.js. Set poweredByHeader: false in the same file.

  2. Add the CSP in report-only mode

    Use the starter policy from the example, under the key Content-Security-Policy-Report-Only. Deploy it, or run npm run build && npm start locally.

  3. Click through the whole app

    Open every page, log in, run a test payment, upload a file. Each console message names a blocked origin and the directive that blocked it.

  4. Add only the origins you trust

    Your Supabase project URL belongs in connect-src, for example https://YOUR_PROJECT.supabase.co. A font host or analytics script goes in its own directive. Leave out anything you don't recognize.

  5. Tighten, then enforce

    Move to the nonce setup to drop 'unsafe-inline', or keep it and write down why. When the console is quiet, rename the key to Content-Security-Policy.

  6. Re-check after every new script

    A chat widget, analytics tag or payment script is a new origin. Add its origins to the policy, then run the curl check to confirm the headers are still sent.

Test it yourself

Run these against your own app only. Don't point them at sites you don't own.

curl -sI https://YOUR_APP_URL | grep -iE 'content-security|strict-transport|x-content-type|referrer-policy|x-frame|permissions-policy|x-powered-by'

A good result shows one line for each header you set and no x-powered-by line. A bad result is a missing header, x-powered-by: Next.js, or an old X-XSS-Protection: 1; mode=block.

In DevTools, open the Network tab and click the page request. The Response Headers panel shows the same list. With report-only mode on, the Console logs every resource the policy would block. Quiet means enforcing won't break anything, but the policy isn't tight while 'unsafe-inline' is still in script-src.

If you use a nonce, fetch the page twice and compare:

curl -s https://YOUR_APP_URL | grep -o 'nonce="[^"]*"' | head -2
curl -s https://YOUR_APP_URL | grep -o 'nonce="[^"]*"' | head -2

Good: nonce attributes appear and the values differ between requests. Bad: no nonce at all, or the same value twice. No nonce can also mean the page is static or the matcher skipped it.

For a second opinion, paste your URL into securityheaders.com. It grades which headers are present, not whether your CSP is tight, so look for 'unsafe-inline' and wildcards yourself.

Fix prompt you can paste

Give this to Cursor or Claude Code from the root of your project.

FIX PROMPT
Audit the HTTP security headers in this Next.js App Router project.

1. Search next.config.js, next.config.mjs, next.config.ts, proxy.ts and middleware.ts for headers() entries and any code that sets Content-Security-Policy, X-XSS-Protection, Expect-CT, X-Powered-By or Access-Control-Allow-Origin.
2. Flag every CSP that contains default-src *, a bare *, 'unsafe-eval' outside a development check, or 'unsafe-inline' in script-src. Report each finding as file path and line number.
3. Make sure one headers() entry for /(.*) sets: Strict-Transport-Security (max-age=63072000; includeSubDomains, no preload), X-Content-Type-Options nosniff, Referrer-Policy strict-origin-when-cross-origin, X-Frame-Options DENY, and a restrictive Permissions-Policy. Set poweredByHeader to false.
4. Add a Content-Security-Policy-Report-Only header with default-src 'self', object-src 'none', base-uri 'self' and frame-ancestors 'none'. List every external origin the app actually loads (fonts, analytics, Supabase, payment scripts) and add each to the right directive.
5. Remove X-XSS-Protection and Expect-CT. Do not touch CORS, auth or middleware logic.
6. Do not enforce the CSP yet. Explain each change in one sentence and tell me how to verify it with curl -I.

Add the curl check to your vibe coding security checklist so it runs before each release. If you'd like a second look at your deployed app, you can run it through Leakcade.

Further reading

  1. Next.js: Content Security Policy guide
  2. Next.js: headers in next.config.js
  3. MDN: Content Security Policy (CSP)
  4. OWASP: HTTP Headers Cheat Sheet

FAQ

What security headers should I set on a Next.js app?

Start with Content-Security-Policy, Strict-Transport-Security, X-Content-Type-Options set to nosniff, Referrer-Policy, a framing rule (X-Frame-Options or frame-ancestors) and Permissions-Policy. Set them in headers() in next.config.js. Also set poweredByHeader: false.

How do I add a Content Security Policy in Next.js?

You have two options. Send a static policy from headers() in next.config.js, which usually needs 'unsafe-inline' for scripts. Or generate a nonce per request in proxy.ts (middleware.ts on older versions). Start in report-only mode either way.

Does Next.js add security headers by default?

It doesn't set a CSP for you. It does send X-Powered-By unless you turn it off. Your host may add other headers, so run curl -I against your deployed URL to see what actually arrives.

Can a CSP break my Next.js app?

Yes. An enforced policy blocks anything it doesn't list, including analytics, fonts and API calls to services like Supabase. Ship it as Content-Security-Policy-Report-Only first and fix what the console reports.

Is X-XSS-Protection still needed?

No. OWASP says to leave it out or set it to 0, because the old browser filter could introduce XSS holes. Rely on a CSP and on fixing the underlying bug.

Want a second pair of eyes?

We review your whole repo and write a fix prompt for every finding. $19, report within an hour.

Check my repo

Leakcade is an automated check. It can miss vulnerabilities and is not a substitute for a professional security audit or pentest. Provided as is. You stay responsible for your code. Where the law allows, our liability is limited to what you paid ($19). Terms

Patch Notes by Leakcade

A short weekly email: what changed in AI coding tools, one real security mistake, one prompt you can reuse.