SecureStartKit
SecurityFeaturesPricingDocsBlogChangelog
Sign inBuy Now
May 27, 2026·Security·SecureStartKit Team

Next.js Security Headers: From Zero Defaults to A+ [2026]

Next.js ships zero security headers by default. The 7-header next.config.ts setup that gets you to A+, plus the COOP/Stripe popup trap.

Summarize with AI

On this page

  • Table of contents
  • Why does Next.js ship zero security headers by default?
  • Where should you set security headers in Next.js?
  • What does each security header actually do?
  • How do you avoid the COOP trap that breaks Stripe Checkout?
  • How do you test that your headers actually work?
  • What security-header configuration ships in SecureStartKit?
  • Where headers stop and architecture starts

On this page

  • Table of contents
  • Why does Next.js ship zero security headers by default?
  • Where should you set security headers in Next.js?
  • What does each security header actually do?
  • How do you avoid the COOP trap that breaks Stripe Checkout?
  • How do you test that your headers actually work?
  • What security-header configuration ships in SecureStartKit?
  • Where headers stop and architecture starts

A freshly initialized Next.js 15 or 16 project ships zero security headers on its responses. The framework's own documentation acknowledges this directly: the headers() async function in next.config.ts is the canonical setup path [1], with a separate guide for the dynamic header that needs middleware [2]. Until you add either of those, the browser enforces no HSTS, no MIME-sniffing protection, no clickjacking defense, and no CSP, regardless of where you deploy.

This is the cluster 2.4 pillar for Application Security. It catalogues every header Next.js leaves to you, the configuration that gets a deployed app to a securityheaders.com A+, and the COOP trap that quietly breaks Stripe Checkout when you over-tighten. The deep nonce-based CSP pattern lives in the CSRF, XSS, and SQL injection pillar; this pillar earns its place by handling the full header taxonomy and the cross-origin trade-offs that pillar does not.

TL;DR:

  • Next.js ships nothing. Out of the box, securityheaders.com grades a stock Next.js app at F. The fix is a single headers() block in next.config.ts [1].
  • 6 of the 7 belong in next.config.ts. HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, X-DNS-Prefetch-Control, and Permissions-Policy are all static values; one block sets them all.
  • CSP with nonces lives in middleware.ts or proxy.ts. Per-request nonces require dynamic rendering and the request-level layer Next.js calls middleware.ts on 15 and proxy.ts on 16 [2].
  • The COOP/Stripe trap is real. Cross-Origin-Opener-Policy: same-origin severs window.opener and breaks Stripe Checkout popups, Stripe Connect, and OAuth flows. Use same-origin-allow-popups [3].
  • A+ requires CSP plus HSTS preload. Without both, the highest grade you can reach is A.

Table of contents

  • Why does Next.js ship zero security headers by default?
  • Where should you set security headers in Next.js?
  • What does each security header actually do?
  • How do you avoid the COOP trap that breaks Stripe Checkout?
  • How do you test that your headers actually work?
  • What security-header configuration ships in SecureStartKit?
  • Where headers stop and architecture starts

Why does Next.js ship zero security headers by default?

The Next.js team treats security headers as a configuration decision, not a framework default. The headers() async function in next.config.ts is documented as the canonical setup path [1], and a separate Content Security Policy guide handles the dynamic header that needs request-level logic [2]. The deliberate consequence: a freshly-deployed Next.js app scores F at securityheaders.com until you ship the config.

The design choice is defensible. App Router supports static, ISR, and dynamic rendering simultaneously, and a one-size header set would either break static generation (nonce-based CSP needs dynamic rendering) or be too loose to matter (a default 'unsafe-inline' CSP is barely better than nothing). Leaving headers as opt-in means the framework cannot ship a footgun.

The cost is borne by indie developers cloning a starter on Friday and deploying on Sunday. The Next.js header docs sit two clicks deep, the CSP guide is its own page, and the connection between "starter clones zero headers" and "your domain shows F on a public scanner" is not advertised. A starter template that does not preconfigure these headers leaves the configuration debt as an exercise for the reader. The architecturally honest stance is to ship 5 of the 7 headers preconfigured in next.config.ts, document the two that are deliberately omitted, and let the buyer decide on CSP vs static rendering. The rest of this post walks through what each header does, where it should live, and which traps are worth knowing before they show up in production.

Where should you set security headers in Next.js?

Static headers belong in next.config.ts via the headers() async function: HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, X-DNS-Prefetch-Control, Permissions-Policy, and the no-nonce variant of CSP all qualify [1]. Dynamic headers that need a per-request value, specifically nonce-based CSP, belong in middleware.ts (Next.js 15) or proxy.ts (Next.js 16, renamed in that release [2]).

The decision rule is binary: does the header value change per request? If no, next.config.ts is correct. If yes, the request-level layer is the only option, because headers() runs at build time and cannot generate a per-request value.

The canonical next.config.ts shape looks like this:

// next.config.ts
import type { NextConfig } from 'next'

const securityHeaders = [
  { key: 'X-Frame-Options', value: 'DENY' },
  { key: 'X-Content-Type-Options', value: 'nosniff' },
  { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
  { key: 'X-DNS-Prefetch-Control', value: 'on' },
  {
    key: 'Strict-Transport-Security',
    value: 'max-age=63072000; includeSubDomains; preload',
  },
]

const nextConfig: NextConfig = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: securityHeaders,
      },
    ]
  },
}

export default nextConfig

The source pattern is a path-to-regexp expression [1]. /(.*) matches every route. You can scope headers tighter (apply a relaxed CSP to /api/og/* only, for instance) by adding more entries with narrower sources. Headers are checked before the filesystem, so they apply uniformly to pages, route handlers, and /public files.

The middleware path is needed only when the value is dynamic. The most common case is a nonce-based CSP: the nonce must be cryptographically unique per request, which means it cannot be baked in at build time. The Next.js CSP guide walks the canonical pattern [2], and the deep version of that code lives in the CSRF, XSS, and SQL injection pillar. The short version: every request runs a small handler that generates a random nonce, sets the Content-Security-Policy header on the response, and passes the nonce to the rendered HTML via the x-nonce request header so React's runtime can attach it to inline scripts.

What does each security header actually do?

The 7 headers Next.js leaves to you each cover one distinct class of browser-side attack: HSTS forces HTTPS, X-Content-Type-Options blocks MIME sniffing, X-Frame-Options stops clickjacking (now superseded by CSP's frame-ancestors per Next.js docs [1]), Referrer-Policy controls URL leakage, Permissions-Policy blocks unrequested browser APIs, X-DNS-Prefetch-Control reduces latency for outbound links, and CSP gates which scripts can execute.

The full taxonomy, with the recommended value and a one-line explanation of what each blocks:

HeaderRecommended valueWhat it blocks
Strict-Transport-Securitymax-age=63072000; includeSubDomains; preloadProtocol downgrade, MITM via HTTP, expired-cert override
X-Content-Type-OptionsnosniffBrowser guessing a Content-Type and executing data as code
X-Frame-OptionsDENYClickjacking via iframe embedding (superseded by CSP frame-ancestors)
Referrer-Policystrict-origin-when-cross-originFull URL leakage to third-party sites
X-DNS-Prefetch-ControlonLatency on outbound link clicks (defensive utility, not security)
Permissions-Policycamera=(), microphone=(), geolocation=(), browsing-topics=()Compromised JS calling sensitive browser APIs
Content-Security-Policydefault-src 'self'; script-src 'self' 'nonce-XXX' 'strict-dynamic'; ...XSS, inline script injection, third-party resource loads

A few entries need elaboration. HSTS at max-age=63072000 (two years) is the value Next.js's own documentation recommends [1] and MDN flags as preload-eligible: the minimum for preload submission is 31536000 seconds, but hstspreload.org recommends 63072000 [4]. The preload directive lets you submit your domain to the browsers' baked-in HSTS list so even a user's first visit goes over HTTPS without a plaintext redirect [4]. The includeSubDomains directive extends the policy to every subdomain, which is the right default for a single-domain SaaS but a trap for organizations with HTTP-only subdomains they have not migrated.

X-Frame-Options is being phased out. The Next.js header docs explicitly note: "This header has been superseded by CSP's frame-ancestors option, which has better support in modern browsers" [1]. The honest practice in 2026 is to ship both: keep X-Frame-Options: DENY for legacy browsers and add frame-ancestors 'none' to your CSP for modern browsers. The Next.js docs themselves still document the X-Frame-Options entry as one of the headers worth setting [1], so dropping it is premature.

Permissions-Policy is the most underused header in the Next.js ecosystem. The value Next.js recommends, camera=(), microphone=(), geolocation=(), browsing-topics=() [1], blocks any script (yours or a compromised third party) from requesting those capabilities. If you do not use the camera, blocking the camera costs nothing and eliminates the abuse path. Allowlist the features you actually use, deny the rest.

Building this from scratch on a new SaaS?

SecureStartKit ships every pattern in this post out of the box: backend-only data access, Zod on every Server Action, RLS deny-all, signed Stripe webhooks with idempotency dedup. One purchase, lifetime updates.

See what's included →Live demo

How do you avoid the COOP trap that breaks Stripe Checkout?

Cross-Origin-Opener-Policy: same-origin process-isolates your tab and severs window.opener for any popup [3]. That breaks Stripe Checkout's popup flow, Stripe Connect's onboarding popup, Google and GitHub OAuth, and any payment or authentication provider that uses Window.open() and relies on the opener reference to post results back. The MDN docs name the exception explicitly: "when using a cross-origin service for OAuth or payments," use same-origin-allow-popups instead [3].

The trap is invisible in development because most teams test Stripe and OAuth flows in the staging environment where COOP is often omitted, and the popup flow only fails on production where COOP was tightened for the security headers grade. The symptom is consistent: the popup opens, the user completes the flow on Stripe's hosted page, the popup closes, and the parent tab never receives the result. No console error, no network failure, no rejection. The parent simply does not learn that the payment succeeded, and the user stares at a stale checkout screen.

The MDN COOP page lists three values worth knowing [3]:

  • unsafe-none is the default. No process isolation, no popup restrictions. Old behavior.
  • same-origin opts in to full process isolation. Cross-origin popups lose window.opener. Maximum cross-origin isolation; breaks every popup-based payment and auth flow.
  • same-origin-allow-popups opts in to process isolation BUT allows popups with COOP: unsafe-none to retain their opener reference. This is the value MDN recommends for OAuth and payment integrations [3].

For a SaaS that integrates Stripe Checkout, OAuth sign-in, or any other popup-based third-party flow, same-origin-allow-popups is the right value. Tightening to same-origin because securityheaders.com awards more points for it is a textbook case of optimizing for the grader instead of the product. The grade gain is small; the broken checkout is total.

There is one related header worth a brief mention. Cross-Origin-Embedder-Policy: require-corp enables cross-origin isolation for advanced features like SharedArrayBuffer, but it also requires every cross-origin resource (images, fonts, scripts) to opt in via Cross-Origin-Resource-Policy. Most SaaS apps do not need it. If you are not using SharedArrayBuffer or performance.measureUserAgentSpecificMemory(), omit COEP. Setting it speculatively will break legitimate cross-origin resources the moment a third-party CDN forgets to send Cross-Origin-Resource-Policy: cross-origin.

How do you test that your headers actually work?

securityheaders.com is the industry-standard scanner: free, no signup, grades your domain A+ to F based on the presence and configuration of HSTS, CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, and Permissions-Policy. Paste your URL, wait three seconds, and read the report. An A+ requires all 7 headers configured correctly plus HSTS with the preload directive plus a CSP without 'unsafe-inline' on script-src.

Three checks are worth running before the deploy:

Local check with curl. From your terminal, curl -I https://yourdomain.com (capital -I) dumps just the response headers. The 7 headers should be present and the values should match what you have in next.config.ts. The advantage over the browser DevTools view: curl shows the response exactly as the origin server emitted it, with no transformation by the browser or any extension.

Browser DevTools Network tab. Open DevTools, refresh the page, click any document request, and look at the Response Headers panel. This is the right check for verifying that middleware-emitted headers (nonce-based CSP) are actually arriving with the dynamic value. If the Content-Security-Policy header is missing on a route that should have it, the middleware.ts matcher config is probably excluding that path.

securityheaders.com after deploy. Run the scanner on the live URL, screenshot the result, and store it in your launch checklist. The grade is a public-facing signal; some procurement processes (Vanta-style compliance audits, security questionnaires) ask for it directly. The security audit checklist includes this scan as one of the 30 production checks; the pre-launch security audit walks through reading the report.

One development-only caveat. The Next.js CSP guide notes that in development mode, the CSP must include 'unsafe-eval' because React uses eval to provide enhanced debugging information [2]. Production does not need it. Test your headers against the production build (npm run build && npm run start), not the dev server; otherwise you will either ship a too-loose CSP or chase phantom violations that only happen in the dev runtime.

What security-header configuration ships in SecureStartKit?

SecureStartKit ships 5 of the 7 headers in next.config.ts on every route via the headers() async function: X-Frame-Options DENY, X-Content-Type-Options nosniff, Referrer-Policy strict-origin-when-cross-origin, X-DNS-Prefetch-Control on, and HSTS at max-age=63072000; includeSubDomains; preload. The remaining two, Content-Security-Policy and Permissions-Policy, ship as opt-in configuration patterns rather than defaults, for reasons documented below.

The shipped configuration is the same next.config.ts block shown earlier in this post. There is no separate file; no plugin; no Vercel-specific configuration. The five headers are emitted by the standard Next.js mechanism documented in the official headers reference [1].

The two deliberate omissions are worth naming.

CSP is opt-in because nonce-based CSP forces dynamic rendering. The Next.js CSP guide is explicit: nonces require dynamic rendering on every page that emits the CSP header [2], which means static optimization and Incremental Static Regeneration are disabled for nonce-protected routes. For a marketing site that serves cached pages at the edge, this is a 30 to 200 millisecond TTFB regression. The template ships static rendering for marketing pages and dynamic rendering for dashboard pages, and treating CSP as something the buyer turns on per route (not as a one-size default) keeps both optimization paths available. The non-nonce CSP fallback (a static value in next.config.ts with 'unsafe-inline' on script-src) is documented as the static-friendly compromise [2]; the experimental Subresource Integrity feature for hash-based CSP is documented as the in-progress alternative [2].

Permissions-Policy is opt-in because the right value depends on the app. A SaaS that uses geolocation for a "find nearest office" feature cannot ship geolocation=(). A SaaS that uses the camera for QR-code scanning cannot ship camera=(). The Next.js docs recommend a specific deny-all value [1], but the right value for any individual app is "deny everything I do not actively use." The recommended baseline is documented in the security hardening checklist; the application owner enables it once they have audited their feature surface.

The two free tools that surround this work are the security headers generator (configure each header, copy-paste the next.config.ts block) and the CORS configuration generator (handles cross-origin headers for API routes, which are an adjacent but distinct surface). Both are calibrated for the default-deny posture this post recommends.

Where headers stop and architecture starts

Security headers are a browser-side mitigation. They tell the browser to refuse to execute a script that lacks a nonce, to refuse to render inside an iframe, to refuse to leak a referrer. They do nothing about a Server Action that takes unvalidated input and writes to your database. They do nothing about a Supabase query that bypasses Row Level Security. They do nothing about a Stripe webhook that processes an unverified signature.

The full defense stack for a Next.js SaaS in 2026 layers headers on top of architectural controls. Backend-only data access means the browser never receives credentials with database power. RLS-default-deny means even a leaked anon key cannot enumerate rows it should not see. Zod validation on every Server Action means input is structurally checked before it reaches business logic. Bot protection on Vercel means abusive automated traffic gets classified before it hits a function invocation. The headers in this post sit at the browser boundary; the architecture sits at the data and request boundaries.

This is what SecureStartKit is built around: a starter that ships the headers preconfigured, the architecture preconfigured, and the trade-offs documented so the buyer knows which knob does what. The 7 headers above are the cheapest, fastest layer of the stack. They take one block in next.config.ts and ten minutes to deploy. There is no good reason to be at securityheaders.com F on launch day.

Frequently Asked Questions

Does Next.js set any security headers by default?
No. A freshly initialized Next.js 15 or 16 project ships zero security headers on its responses. The framework documents the `headers()` async function in `next.config.ts` as the canonical setup path [1] and a separate Content Security Policy guide for the dynamic header that needs middleware or proxy.ts [2]. Until you add either of these, the browser does not enforce HSTS, MIME-sniffing protection, clickjacking protection, or any CSP, regardless of where you deploy.
Where do you set security headers in Next.js, next.config.ts or middleware.ts?
Static headers go in `next.config.ts` via the `headers()` async function: HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, X-DNS-Prefetch-Control, Permissions-Policy, and the no-nonce variant of CSP all live there [1]. Dynamic headers that need a per-request value, specifically nonce-based CSP, belong in `middleware.ts` on Next.js 15 or `proxy.ts` on Next.js 16, since the nonce has to be generated fresh on every request [2]. Use `next.config.ts` whenever the value is static; reach for the middleware layer only when the value must change per request.
Why does Cross-Origin-Opener-Policy break Stripe Checkout?
Cross-Origin-Opener-Policy `same-origin` process-isolates your tab and severs `window.opener` for any popup [3]. Stripe Checkout's hosted-popup flow, Stripe Connect's onboarding popup, Google OAuth, and any payment or auth provider that uses `Window.open()` rely on the opener reference to post messages back. MDN's own COOP page calls out OAuth and payments as the canonical exception: 'when using a cross-origin service for OAuth or payments,' use `same-origin-allow-popups` instead [3]. That value keeps the cross-origin isolation benefit while allowing trusted popups to retain their opener handle.
What does securityheaders.com actually check?
securityheaders.com is a free scanner that grades your domain A+ to F based on the presence and configuration of the headers Next.js leaves to you: HSTS, Content-Security-Policy, X-Frame-Options (or `frame-ancestors` in CSP), X-Content-Type-Options, Referrer-Policy, and Permissions-Policy. A passing grade requires all of them set; an A+ requires a strict CSP and HSTS with the preload directive. The scanner runs in the open; you can verify locally with `curl -I https://yourdomain.com` and the browser DevTools Network panel.
Should X-Frame-Options still be set if you already have a CSP frame-ancestors directive?
Yes for now, no in the long term. The Next.js documentation explicitly notes that 'this header has been superseded by CSP's `frame-ancestors` option, which has better support in modern browsers' [1]. The reason both are typically shipped together: very old browsers ignore CSP's `frame-ancestors` but honor `X-Frame-Options`. Ship both, prefer CSP's directive in the policy itself, and treat X-Frame-Options as a backwards-compatibility belt around the modern suspenders. Once your analytics show your traffic is overwhelmingly on modern browsers, the X-Frame-Options entry can drop.
Can you use a nonce-based CSP with static rendering or ISR?
No. The Next.js CSP guide is explicit that nonces require dynamic rendering on every page that emits the CSP header, since the nonce must be regenerated per request [2]. The guide also notes that 'Partial Prerendering (PPR) is incompatible with nonce-based CSP since static shell scripts won't have access to the nonce' [2]. Two real options for keeping static rendering: ship a CSP without nonces (using `'unsafe-inline'` for scripts, weaker) in `next.config.ts`, or use Next.js's experimental Subresource Integrity feature for hash-based CSP, which keeps static generation viable [2].

Built for developers who care about security

SecureStartKit ships with these patterns out of the box.

Backend-only data access, Zod validation on every input, RLS enabled, Stripe webhooks verified. One purchase, lifetime updates.

View PricingSee the template in action

References

  1. headers, Next.js Configuration Reference— nextjs.org
  2. How to set a Content Security Policy (CSP) for your Next.js application— nextjs.org
  3. Cross-Origin-Opener-Policy, MDN Web Docs— developer.mozilla.org
  4. Strict-Transport-Security, MDN Web Docs— developer.mozilla.org

Related Posts

May 16, 2026·Security

OWASP Top 10:2025 for Next.js + Supabase Apps

OWASP Top 10:2025 mapped to Next.js + Supabase failure modes plus the architectural defenses that prevent each category. With 2026 CVEs.

Mar 16, 2026·Security

Next.js Security Checklist: 12 Steps [2026]

A production security checklist for Next.js apps. Covers HTTP headers, CSP, environment variables, Server Actions, RLS, webhook verification, and more.

May 26, 2026·Security

Bot Protection on Vercel: The Cost-Attribution View [2026]

Bot protection on Vercel in 2026: why a 403'd bot still costs you, what BotID and the WAF actually stop, when Cloudflare in front is worth it.