Security Guides

Secure Next.js: A Production Security Checklist

Next.js ships with sensible routing defaults but zero security headers. A freshly scaffolded Next.js app scores F on securityheaders.com. This guide fixes that — in order of impact.

Everything here is applicable to an existing Next.js 13+ App Router project. Most changes take under 30 minutes.

1. Security headers via next.config.ts

The highest-leverage change: a single block of headers that protects against clickjacking, MIME sniffing, information leakage, and enforces HTTPS.

// next.config.ts
import type { NextConfig } from 'next';
 
const nextConfig: NextConfig = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: [
          // Prevent MIME sniffing
          { key: 'X-Content-Type-Options', value: 'nosniff' },
          // Prevent clickjacking (use CSP frame-ancestors for full control)
          { key: 'X-Frame-Options', value: 'SAMEORIGIN' },
          // Stop IE from activating dangerous content
          { key: 'X-XSS-Protection', value: '0' },  // Disable — CSP replaces this
          // Control referrer leakage
          { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
          // Restrict browser APIs available to your page and iframes
          { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(), browsing-topics=()' },
          // Enforce HTTPS for 1 year, include subdomains
          { key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains; preload' },
        ],
      },
    ];
  },
};
 
export default nextConfig;

Note: X-XSS-Protection: 0 intentionally disables the legacy XSS auditor in older browsers. Modern browsers don't use it, and it has known bypasses. CSP is the correct replacement.

2. Content-Security-Policy with nonces

This is the hardest part of Next.js security, but it's the most important. Without CSP, an XSS vulnerability — in your code or any dependency — gives an attacker full access to your users' sessions.

The recommended approach for App Router is nonce-based CSP via middleware:

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
 
export function middleware(request: NextRequest) {
  // Generate a fresh cryptographic nonce for every request
  const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
 
  const csp = [
    `default-src 'self'`,
    // Nonce covers root bundle; strict-dynamic propagates trust to dynamic imports
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    // Unsafe-inline only in style-src — acceptable because CSS can't exfiltrate tokens
    `style-src 'self' 'unsafe-inline'`,
    `img-src 'self' blob: data: https:`,
    `font-src 'self'`,
    `connect-src 'self'`,
    `object-src 'none'`,
    `base-uri 'self'`,
    `form-action 'self'`,
    `frame-ancestors 'none'`,
    `upgrade-insecure-requests`,
  ].join('; ');
 
  const requestHeaders = new Headers(request.headers);
  requestHeaders.set('x-nonce', nonce);
 
  const response = NextResponse.next({ request: { headers: requestHeaders } });
  response.headers.set('Content-Security-Policy', csp);
 
  return response;
}
 
export const config = {
  // Exclude static assets — they don't need CSP, and processing them wastes CPU
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
};

Then read the nonce in your root layout and pass it to the script tag that bootstraps Next.js:

// app/layout.tsx
import { headers } from 'next/headers';
import type { ReactNode } from 'react';
 
export default async function RootLayout({ children }: { children: ReactNode }) {
  const headersList = await headers();
  const nonce = headersList.get('x-nonce') ?? '';
 
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

If you use Google Analytics, Google Fonts, or other third-party scripts, you'll need to add their domains to the appropriate directives (script-src, connect-src, font-src, img-src). Use CSP Evaluator to validate your final policy.

3. Third-party scripts with nonces

Next.js Script component supports nonces directly:

// app/layout.tsx
import Script from 'next/script';
import { headers } from 'next/headers';
 
export default async function RootLayout({ children }: { children: ReactNode }) {
  const nonce = (await headers()).get('x-nonce') ?? '';
 
  return (
    <html>
      <body>
        {children}
        <Script
          src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXX"
          strategy="afterInteractive"
          nonce={nonce}
        />
      </body>
    </html>
  );
}

And add GTM to your CSP:

`script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https://www.googletagmanager.com`,
`connect-src 'self' https://www.google-analytics.com`,
`img-src 'self' blob: data: https://www.google-analytics.com`,

4. Secure cookies

Every cookie in your application should have explicit security attributes. A session cookie without HttpOnly is readable by any JavaScript on the page — including XSS payloads.

// app/api/auth/route.ts
import { cookies } from 'next/headers';
import type { NextResponse } from 'next/server';
 
export async function POST(request: Request) {
  const cookieStore = await cookies();
 
  // Session token — never accessible to JS
  cookieStore.set('session', sessionToken, {
    httpOnly: true,      // JS cannot read this
    secure: true,        // HTTPS only
    sameSite: 'lax',     // CSRF protection (use 'strict' if no cross-site nav needed)
    path: '/',
    maxAge: 60 * 60 * 24 * 7,  // 7 days
  });
 
  // Preference cookies — can be JS-accessible, but still secure
  cookieStore.set('theme', 'dark', {
    httpOnly: false,     // OK — not sensitive
    secure: true,
    sameSite: 'lax',
    path: '/',
    maxAge: 60 * 60 * 24 * 365,
  });
}

SameSite values:

  • strict — cookie never sent on cross-site requests (breaks OAuth flows)
  • lax — cookie sent on top-level navigation GET requests only (recommended default)
  • none — cookie always sent cross-site; requires Secure to be set

5. Environment variable hygiene

Next.js has a sharp edge: any variable prefixed with NEXT_PUBLIC_ is embedded in the client bundle and visible to every visitor.

# .env.local
NEXT_PUBLIC_API_URL=https://api.example.com       # ✓ fine — not a secret
NEXT_PUBLIC_SITE_NAME=My App                       # ✓ fine
 
DATABASE_URL=postgres://...                        # ✓ fine — server only
STRIPE_SECRET_KEY=sk_live_...                      # ✓ fine — server only
 
# NEVER do these:
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_...          # ✗ exposed to every visitor
NEXT_PUBLIC_DATABASE_URL=postgres://...            # ✗ exposed to every visitor
NEXT_PUBLIC_OPENAI_KEY=sk-...                      # ✗ exposed to every visitor

Check your deployed site with PatchVex to detect accidentally exposed API keys in HTTP responses — this catches keys embedded in HTML, JavaScript bundles, and API responses.

6. API route authorization

Every Next.js API route is publicly accessible by default. Never assume a route is internal:

// app/api/admin/users/route.ts
import { auth } from '@/lib/auth';
import { NextResponse } from 'next/server';
 
export async function GET(request: Request) {
  const session = await auth(request);
 
  // Always check auth before doing anything
  if (!session || session.role !== 'admin') {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }
 
  // Now it's safe to proceed
  const users = await db.user.findMany();
  return NextResponse.json(users);
}

For internal APIs called only by your own frontend, verify the Origin header:

export async function POST(request: Request) {
  const origin = request.headers.get('origin');
  const allowed = process.env.NEXT_PUBLIC_SITE_URL;
 
  if (origin !== allowed) {
    return new Response(null, { status: 403 });
  }
  // ...
}

7. CORS for API routes

If you're building an API consumed by other origins, be explicit:

// app/api/public/route.ts
const ALLOWED_ORIGINS = [
  'https://app.example.com',
  'https://example.com',
  // Add staging/dev origins only in non-production
  ...(process.env.NODE_ENV !== 'production' ? ['http://localhost:3000'] : []),
];
 
function corsHeaders(origin: string | null) {
  const isAllowed = origin && ALLOWED_ORIGINS.includes(origin);
  return {
    'Access-Control-Allow-Origin': isAllowed ? origin : '',
    'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type, Authorization',
    'Access-Control-Max-Age': '86400',
    'Vary': 'Origin',
  };
}
 
export async function OPTIONS(request: Request) {
  const origin = request.headers.get('origin');
  return new Response(null, {
    status: 204,
    headers: corsHeaders(origin),
  });
}
 
export async function GET(request: Request) {
  const origin = request.headers.get('origin');
  return NextResponse.json({ data: '...' }, {
    headers: corsHeaders(origin),
  });
}

Never set Access-Control-Allow-Origin: * on endpoints that use cookies or Authorization headers. The * wildcard is incompatible with Access-Control-Allow-Credentials: true and won't work anyway — the browser will block it.

8. Rate limiting

Without rate limiting, your API endpoints are open to brute force, credential stuffing, and enumeration. Next.js doesn't include rate limiting — add it in middleware:

// middleware.ts (extend the existing one)
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
import { NextResponse } from 'next/server';
 
const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, '10 s'),
});
 
export async function middleware(request: NextRequest) {
  // Rate limit API routes only
  if (request.nextUrl.pathname.startsWith('/api/')) {
    const ip = request.ip ?? '127.0.0.1';
    const { success, limit, remaining } = await ratelimit.limit(ip);
 
    if (!success) {
      return NextResponse.json(
        { error: 'Too many requests' },
        {
          status: 429,
          headers: {
            'X-RateLimit-Limit': limit.toString(),
            'X-RateLimit-Remaining': remaining.toString(),
            'Retry-After': '10',
          },
        }
      );
    }
  }
 
  // ... rest of middleware
}

9. Dependency hygiene

Run npm audit before every production deploy. Treat high-severity vulnerabilities as blocking:

# Check for vulnerabilities
npm audit
 
# Fix automatically (safe fixes only)
npm audit fix
 
# See what's fixable vs requires manual intervention
npm audit --json | jq '.vulnerabilities | to_entries[] | select(.value.severity == "high" or .value.severity == "critical")'

Set up Dependabot or Renovate to open PRs for security updates automatically.

10. Verify with PatchVex

After deploying these changes, scan your site to confirm everything is configured correctly:

  1. Go to patchvex.com/products/scanner
  2. Enter your production URL
  3. Review the security report — it checks all the controls covered in this guide

Pay particular attention to:

  • CSP presence and quality (nonce vs unsafe-inline)
  • Cookie attributes on Set-Cookie headers
  • HSTS max-age and includeSubDomains
  • Any API keys appearing in HTTP responses

Quick reference checklist

ControlStatus after this guide
X-Content-Type-Optionsnosniff
X-Frame-OptionsSAMEORIGIN
Referrer-Policystrict-origin-when-cross-origin
Permissions-Policy✓ Restrictive defaults
Strict-Transport-Security✓ 1 year + includeSubDomains
Content-Security-Policy✓ Nonce-based
Cookie HttpOnly✓ On all session cookies
Cookie Secure✓ On all cookies
Cookie SameSite✓ lax or strict
API authorization✓ Checked on every route
CORS✓ Explicit allowlist
Rate limiting✓ On API routes
Environment variables✓ No secrets in NEXT_PUBLIC_