IAMUVIN

Cybersecurity & Ethical Hacking

Security Headers in Next.js: The Complete Implementation Guide

Uvin Vindula·May 19, 2025·11 min read

Last updated: April 14, 2026

Share

TL;DR

Security headers are the cheapest, most effective defence layer you can add to a Next.js application. They cost zero runtime performance, take thirty minutes to implement correctly, and block entire categories of attacks — clickjacking, XSS, MIME sniffing, data leakage, and unauthorized feature access. I set six headers on every project I ship: Content-Security-Policy, Strict-Transport-Security, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, and Permissions-Policy. This guide gives you the exact configurations I use in production, the middleware that applies CSP nonces for inline scripts, and the testing workflow that confirms everything works before it hits Vercel. If your site does not score A+ on securityheaders.com, this article will get you there.


Why Headers Matter

Most developers think about security at the application layer — input validation, auth checks, parameterized queries. That is necessary but not sufficient. Security headers operate at the browser layer. They tell the browser what your application is allowed to do before any of your JavaScript even executes.

Without security headers, your Next.js app is vulnerable to attacks that your application code cannot prevent:

  • Clickjacking. An attacker embeds your site in an iframe on their domain. Your users think they are clicking buttons on your site. They are actually clicking hidden elements on the attacker's page. X-Frame-Options stops this.
  • XSS escalation. Even if you have one XSS vulnerability, a strong Content-Security-Policy prevents the attacker from loading external scripts, exfiltrating data to their server, or injecting inline scripts. CSP turns a critical vulnerability into a contained one.
  • MIME sniffing attacks. A browser guesses that your uploaded .txt file is actually HTML and executes it. X-Content-Type-Options stops this.
  • Data leakage through referrers. Your internal URLs, including tokens and session IDs in query parameters, get sent to third-party sites through the Referer header. Referrer-Policy controls this.
  • Unnecessary API access. Your marketing site does not need camera, microphone, or geolocation access. But without Permissions-Policy, any injected script can request them.

I run security audits where the first thing I check is the response headers. If I see X-Frame-Options missing or Content-Security-Policy absent, I already know the application was built without security in mind. Headers are the signal. They tell me whether the developer treated security as a priority or an afterthought.

The six headers I set on every Next.js project are:

  1. Content-Security-Policy — controls what resources can load
  2. Strict-Transport-Security — forces HTTPS
  3. X-Frame-Options — prevents clickjacking
  4. X-Content-Type-Options — prevents MIME sniffing
  5. Referrer-Policy — controls referrer information leakage
  6. Permissions-Policy — restricts browser feature access

Let's go through each one, then I will show you exactly how to implement them all in your Next.js project.


Content-Security-Policy -- The Hard One

Content-Security-Policy (CSP) is the most powerful security header and the most difficult to get right. It is a whitelist of content sources. Everything not explicitly allowed is blocked. If you get it wrong, your fonts won't load, your analytics break, and your users see a blank page. If you skip it entirely, you leave the door open for XSS to do real damage.

A CSP policy is a semicolon-separated list of directives. Each directive controls a resource type:

Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  font-src 'self';
  connect-src 'self';
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';

Here is what each directive does:

  • `default-src 'self'` — fallback for all resource types. Only allow resources from your own origin.
  • `script-src 'self'` — only allow scripts from your origin. No inline scripts, no eval(), no scripts from CDNs unless you whitelist them.
  • `style-src 'self' 'unsafe-inline'` — allow styles from your origin plus inline styles. Tailwind CSS and most CSS-in-JS solutions require 'unsafe-inline' for styles. This is an acceptable trade-off because style injection is far less dangerous than script injection.
  • `img-src 'self' data: https:` — allow images from your origin, data URIs (for base64 inline images), and any HTTPS source.
  • `font-src 'self'` — only allow fonts from your origin. If you use Google Fonts, add https://fonts.gstatic.com.
  • `connect-src 'self'` — controls where fetch(), XMLHttpRequest, and WebSocket can connect. Add your API domains here.
  • `frame-ancestors 'none'` — no one can embed your site in an iframe. This is the CSP equivalent of X-Frame-Options: DENY.
  • `base-uri 'self'` — prevents <base> tag injection, which can redirect all relative URLs to an attacker's domain.
  • `form-action 'self'` — forms can only submit to your own origin.

The critical rule: never use `'unsafe-eval'` in `script-src`. It re-enables eval() and defeats the purpose of CSP. If a library requires eval(), find a different library.

For style-src, 'unsafe-inline' is a pragmatic choice. The alternative is using style nonces or hashes, which are impractical with Tailwind CSS and most component libraries. The security risk from inline styles is minimal compared to inline scripts.

Common CSP Additions

If you use Google Analytics:

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

If you use Vercel Analytics and Speed Insights:

script-src 'self' https://va.vercel-scripts.com;
connect-src 'self' https://vitals.vercel-insights.com;

If you use external fonts:

font-src 'self' https://fonts.gstatic.com;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;

Every third-party service you add to your CSP widens the attack surface. Each additional domain is a domain that, if compromised, can inject scripts into your application. Keep it minimal.


Strict-Transport-Security

HSTS tells the browser: "Never connect to this domain over plain HTTP. Always use HTTPS. Remember this for the next two years."

Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
  • `max-age=63072000` — the browser remembers this directive for two years (63,072,000 seconds). Even if a user types http://yourdomain.com, the browser upgrades it to HTTPS automatically without making the insecure request first.
  • `includeSubDomains` — applies to all subdomains. api.yourdomain.com, staging.yourdomain.com, everything.
  • `preload` — signals that you want to be included in the HSTS preload list, which is hardcoded into Chrome, Firefox, Safari, and Edge. Once you are on the preload list, browsers will never connect to your domain over HTTP, even on the first visit.

Before adding preload, make sure every subdomain supports HTTPS. If you have a subdomain that only works over HTTP, includeSubDomains will break it. Submit your domain at hstspreload.org after you have confirmed everything works.

Why this matters: without HSTS, the very first request to your site on a new browser can be intercepted by a man-in-the-middle attack. The attacker downgrades the connection from HTTPS to HTTP, intercepts credentials, and the user never notices. HSTS eliminates this attack vector entirely.


X-Frame-Options

X-Frame-Options: DENY

This header prevents your site from being embedded in <iframe>, <frame>, <embed>, or <object> elements on other domains. It is the primary defence against clickjacking attacks.

There are two valid values:

  • `DENY` — no one can frame your site, including your own domain.
  • `SAMEORIGIN` — only your own domain can frame your site.

I use DENY on every project unless I have a specific requirement for iframes (like an embeddable widget). If you need SAMEORIGIN, you probably know why. Default to DENY.

Note that frame-ancestors 'none' in CSP does the same thing as X-Frame-Options: DENY. I set both because older browsers may not support CSP but do support X-Frame-Options. Defence in depth.


X-Content-Type-Options

X-Content-Type-Options: nosniff

This is the simplest header and there is no reason to ever omit it. It tells the browser: "Do not try to guess the MIME type of a response. Trust the Content-Type header I send you."

Without nosniff, a browser might interpret a file served as text/plain as text/html if it looks like HTML. An attacker uploads a text file containing <script>alert('xss')</script>, your server serves it with Content-Type: text/plain, and a browser without nosniff executes it as HTML.

One header. One value. Always set it.


Referrer-Policy

Referrer-Policy: strict-origin-when-cross-origin

This controls what information is sent in the Referer header when a user navigates from your site to another site.

The options, from most restrictive to least:

  • `no-referrer` — never send any referrer information. Maximum privacy but breaks analytics on other sites.
  • `strict-origin-when-cross-origin` — send the full URL for same-origin requests, only the origin (domain) for cross-origin requests, and nothing when downgrading from HTTPS to HTTP. This is my default.
  • `origin-when-cross-origin` — same as above but still sends the origin even on HTTPS-to-HTTP downgrades.
  • `same-origin` — only send referrer for same-origin requests. Nothing for cross-origin.

strict-origin-when-cross-origin is the right balance for most applications. Your internal navigation preserves the full referrer (useful for your own analytics), but external sites only see your domain name, not the specific page your user was on. And if someone follows a link from your HTTPS site to an HTTP site, no referrer is sent at all.

Why this matters: I have seen applications where session tokens or reset tokens were included in URLs. Without a restrictive referrer policy, those tokens leak to every third-party resource loaded on the page — analytics scripts, embedded videos, external images. strict-origin-when-cross-origin prevents the URL path from leaking to third parties.


Permissions-Policy

Permissions-Policy: camera=(), microphone=(), geolocation=(), interest-cohort=()

Permissions-Policy (formerly Feature-Policy) restricts which browser APIs your site can access. The () syntax means "no one can use this feature, not even this page."

My standard configuration:

Permissions-Policy:
  camera=(),
  microphone=(),
  geolocation=(),
  interest-cohort=(),
  browsing-topics=(),
  payment=(),
  usb=(),
  magnetometer=(),
  gyroscope=(),
  accelerometer=()
  • `camera=()` — no script on your page can access the camera. Not your code, not an injected script, not an iframe.
  • `microphone=()` — same for microphone.
  • `geolocation=()` — same for location access.
  • `interest-cohort=()` — opts out of Google's Federated Learning of Cohorts (FLoC). Protects user privacy.
  • `browsing-topics=()` — opts out of the Topics API, FLoC's successor.
  • `payment=()` — disables the Payment Request API unless you are building a checkout.

If your application legitimately needs camera access (a video call feature, for example), change camera=() to camera=(self) to allow it only from your own origin.

The point is explicit opt-in. You are declaring what your application needs. Everything else is locked down. If an attacker manages to inject a script, that script cannot silently turn on the microphone or track the user's location.


Implementing in next.config.ts

The most straightforward approach for static headers is next.config.ts. These headers apply to all responses served by Next.js:

typescript
// next.config.ts
import type { NextConfig } from "next";

const securityHeaders = [
  {
    key: "Content-Security-Policy",
    value: [
      "default-src 'self'",
      "script-src 'self'",
      "style-src 'self' 'unsafe-inline'",
      "img-src 'self' data: https:",
      "font-src 'self'",
      "connect-src 'self'",
      "frame-ancestors 'none'",
      "base-uri 'self'",
      "form-action 'self'",
    ].join("; "),
  },
  {
    key: "Strict-Transport-Security",
    value: "max-age=63072000; includeSubDomains; preload",
  },
  {
    key: "X-Frame-Options",
    value: "DENY",
  },
  {
    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=(), browsing-topics=()",
  },
  {
    key: "X-DNS-Prefetch-Control",
    value: "on",
  },
];

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

export default nextConfig;

This approach works well for static CSP policies. But if you need inline scripts — and in Next.js you almost always do, because Next.js injects inline scripts for hydration data — you need nonces. That means middleware.


CSP with Nonces for Inline Scripts

A CSP nonce is a one-time random value generated per request. You add it to your CSP header as 'nonce-<value>' and to your inline <script> tags as nonce="<value>". The browser only executes inline scripts whose nonce matches the one in the CSP header. Any injected script without the correct nonce is blocked.

Next.js has built-in support for CSP nonces through middleware. Here is the implementation I use:

typescript
// middleware.ts
import { NextRequest, NextResponse } from "next/server";

export function middleware(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString("base64");

  const cspHeader = `
    default-src 'self';
    script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
    style-src 'self' 'unsafe-inline';
    img-src 'self' data: https:;
    font-src 'self';
    connect-src 'self';
    frame-ancestors 'none';
    base-uri 'self';
    form-action 'self';
    upgrade-insecure-requests;
  `;

  // Remove newlines and extra spaces
  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
  );
  response.headers.set(
    "Strict-Transport-Security",
    "max-age=63072000; includeSubDomains; preload"
  );
  response.headers.set("X-Frame-Options", "DENY");
  response.headers.set("X-Content-Type-Options", "nosniff");
  response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
  response.headers.set(
    "Permissions-Policy",
    "camera=(), microphone=(), geolocation=(), interest-cohort=(), browsing-topics=()"
  );
  response.headers.set("X-DNS-Prefetch-Control", "on");

  return response;
}

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" },
      ],
    },
  ],
};

The key details:

  • `crypto.randomUUID()` generates a cryptographically secure random value. Base64 encoding it gives you a nonce that is safe to embed in HTML.
  • `'strict-dynamic'` is critical. It means "trust scripts loaded by already-trusted scripts." When Next.js hydration runs (trusted via the nonce), any scripts it dynamically loads are also trusted. Without 'strict-dynamic', Next.js code splitting breaks.
  • `x-nonce` header passes the nonce from middleware to your Server Components, where you can read it with headers().
  • `upgrade-insecure-requests` tells the browser to automatically upgrade any HTTP requests to HTTPS.

Now you need to read the nonce in your layout and pass it to Next.js scripts:

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

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

  return (
    <html lang="en">
      <body>
        {children}
        {/* Any inline scripts must include the nonce */}
        <Script
          nonce={nonce}
          strategy="afterInteractive"
          dangerouslySetInnerHTML={{
            __html: `
              // Your inline script here
              console.log('This script has a valid nonce');
            `,
          }}
        />
      </body>
    </html>
  );
}

For third-party scripts like Google Analytics, add the nonce and whitelist the domain:

typescript
// In your layout or a dedicated component
<Script
  nonce={nonce}
  src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"
  strategy="afterInteractive"
/>
<Script
  nonce={nonce}
  id="google-analytics"
  strategy="afterInteractive"
  dangerouslySetInnerHTML={{
    __html: `
      window.dataLayer = window.dataLayer || [];
      function gtag(){dataLayer.push(arguments);}
      gtag('js', new Date());
      gtag('config', 'G-XXXXXXXXXX');
    `,
  }}
/>

And update your CSP script-src to include the Google Tag Manager domain:

script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https://www.googletagmanager.com;

Testing Your Headers

Setting headers is half the job. Testing them is the other half. I use three methods on every project:

1. curl from the command line

bash
curl -I https://yourdomain.com

This returns all response headers. Check that every security header appears with the correct value. I run this against both the production domain and Vercel preview deployments.

2. securityheaders.com

Go to securityheaders.com and enter your URL. It grades your headers from F to A+. Anything below A+ means you are missing something. This is the tool I use for a quick visual check. It highlights exactly which headers are missing and why.

3. Browser DevTools

Open Chrome DevTools, go to the Network tab, click on the document request, and inspect the Response Headers section. This shows you exactly what the browser received, including the full CSP policy. The Console tab will show CSP violation reports — any resource that was blocked by your policy appears as a console error.

4. CSP report-only mode

Before enforcing a strict CSP on a live application, deploy it in report-only mode first:

Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' 'nonce-xxx' 'strict-dynamic'; report-uri /api/csp-report;

This tells the browser to report violations without blocking them. Set up a simple API route to collect reports:

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

export async function POST(request: NextRequest) {
  const report = await request.json();

  // Log to your monitoring service
  console.log("CSP Violation:", JSON.stringify(report, null, 2));

  // In production, send to Sentry, Datadog, or your logging pipeline
  // await logToMonitoring('csp-violation', report);

  return NextResponse.json({ received: true }, { status: 200 });
}

Run in report-only mode for a week on production. Review the violations. Adjust your policy. Then switch from Content-Security-Policy-Report-Only to Content-Security-Policy to enforce it.

This approach prevents you from deploying a CSP that accidentally breaks your application for users. I have seen teams deploy an overly strict CSP without testing, break their checkout flow, and not realize it until revenue dropped. Report-only mode eliminates that risk.


My Production Header Config

Here is the complete middleware configuration I deploy on my production Next.js sites. This is what runs on my projects right now:

typescript
// middleware.ts
import { NextRequest, NextResponse } from "next/server";

function generateNonce(): string {
  return Buffer.from(crypto.randomUUID()).toString("base64");
}

function buildCsp(nonce: string): string {
  const directives = [
    "default-src 'self'",
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https://va.vercel-scripts.com`,
    "style-src 'self' 'unsafe-inline'",
    "img-src 'self' data: https:",
    "font-src 'self'",
    "connect-src 'self' https://vitals.vercel-insights.com",
    "frame-ancestors 'none'",
    "base-uri 'self'",
    "form-action 'self'",
    "upgrade-insecure-requests",
  ];

  return directives.join("; ");
}

export function middleware(request: NextRequest) {
  const nonce = generateNonce();
  const csp = buildCsp(nonce);

  const requestHeaders = new Headers(request.headers);
  requestHeaders.set("x-nonce", nonce);
  requestHeaders.set("Content-Security-Policy", csp);

  const response = NextResponse.next({
    request: { headers: requestHeaders },
  });

  // Content Security Policy
  response.headers.set("Content-Security-Policy", csp);

  // HTTPS enforcement — 2 year max-age with preload
  response.headers.set(
    "Strict-Transport-Security",
    "max-age=63072000; includeSubDomains; preload"
  );

  // Clickjacking protection
  response.headers.set("X-Frame-Options", "DENY");

  // MIME sniffing protection
  response.headers.set("X-Content-Type-Options", "nosniff");

  // Referrer information control
  response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");

  // Browser feature restrictions
  response.headers.set(
    "Permissions-Policy",
    "camera=(), microphone=(), geolocation=(), interest-cohort=(), browsing-topics=(), payment=(), usb=()"
  );

  // DNS prefetch for performance
  response.headers.set("X-DNS-Prefetch-Control", "on");

  return response;
}

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" },
      ],
    },
  ],
};

A few notes on this configuration:

  • Vercel-specific domains are whitelisted for Vercel Analytics (va.vercel-scripts.com) and Speed Insights (vitals.vercel-insights.com). Remove these if you do not use Vercel's analytics.
  • The matcher excludes API routes, static files, and images. API routes get their own headers through Route Handler responses. Static files served from _next/static don't need CSP because they are your own bundled assets.
  • The `missing` array skips middleware for prefetch requests. This prevents unnecessary nonce generation for navigation prefetches that don't render a page.
  • `payment=()` in Permissions-Policy is set because none of my current projects use the Payment Request API. If you integrate Stripe Elements with the Payment Request API (Apple Pay, Google Pay buttons), change this to payment=(self).

If you are not using middleware for other purposes and your CSP can be static (no inline scripts), use the next.config.ts approach instead. It is simpler and there is no middleware overhead. Use middleware when you need nonces.


Key Takeaways

  1. Set all six headers on every project. CSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy. No exceptions. They take thirty minutes to configure and block entire attack categories.
  1. CSP is the hardest header to get right. Start with Content-Security-Policy-Report-Only, monitor violations for a week, then enforce. Never deploy a strict CSP without testing it in report-only mode first.
  1. Use nonces for inline scripts. Next.js injects inline scripts for hydration. CSP nonces with 'strict-dynamic' let those scripts run while blocking everything else. Generate a fresh nonce per request in middleware.
  1. `'unsafe-inline'` for styles is acceptable. Tailwind CSS and most UI libraries require it. The security risk from inline styles is negligible compared to inline scripts. Never use 'unsafe-eval' in script-src.
  1. HSTS with preload is permanent. Once you submit to the preload list, you cannot easily undo it. Verify every subdomain supports HTTPS before adding preload.
  1. Defence in depth. Set both X-Frame-Options: DENY and frame-ancestors 'none' in CSP. Older browsers may support one but not the other.
  1. Test with three tools. curl -I for quick checks, securityheaders.com for grading, and browser DevTools for CSP violation reporting. A+ is the target.
  1. Middleware vs next.config.ts is a real choice. Use next.config.ts for static headers without nonces. Use middleware when you need per-request nonce generation for CSP.
  1. Every third-party script widens your attack surface. Each domain in your CSP is a domain that, if compromised, can inject code into your app. Keep your CSP allowlist as short as possible.
  1. Headers are your first line of defence. They operate at the browser level, before your application code runs. They cannot replace input validation, auth, or parameterized queries — but they catch what those layers miss.

Security headers are not optional. They are the bare minimum. I set them on every project I build, from portfolio sites to DeFi dashboards to e-commerce platforms. If you need a professional security audit for your Next.js application, that includes a full header review along with OWASP Top 10 coverage, auth hardening, and penetration testing.


About the Author

Uvin Vindula is a Web3 and AI engineer based in Sri Lanka and UK, building production-grade applications and delivering security audits under the handle @IAMUVIN. He sets security headers on every project he ships and treats an A+ on securityheaders.com as a deployment requirement, not a nice-to-have. His work spans full-stack development with Next.js and TypeScript, blockchain security with Solidity and Foundry, and AI product engineering with the Claude API. Find him at uvin.lk or reach out at contact@uvin.lk.

Working on a Web3 or AI project?

Share
Uvin Vindula

Uvin Vindula

Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.