How to Audit a Next.js App for Security Misconfigurations and Header Leaks

Vergate Team5 min read

Next.js is fast to build with. It's also fast to deploy with security gaps you never noticed. The framework ships with zero security headers by default, the dev server hides stack traces behind a pretty error overlay, and production builds quietly inline env vars into client bundles.

Here's the audit — step by step, with exact commands and fixes.

#Step 1: Inspect HTTP response headers

The first thing an attacker checks is what your server tells them about itself.

Run this:

bash
curl -sI https://your-site.com | grep -iE "x-powered-by|server:|x-aspnet|x-runtime"

If you see anything — X-Powered-By: Express, Server: Next.js, X-Runtime — you're handing the attacker a roadmap. They know your runtime, your framework version, and in some cases your Node.js version.

Now check what's missing:

bash
curl -sI https://your-site.com | grep -iE "strict-transport|content-security|x-frame-options|x-content-type|permissions-policy|referrer-policy|cross-origin"

A default Next.js app on Vercel is missing most of these. The minimum set:

HeaderWhat it doesValue
Strict-Transport-SecurityForces HTTPS for 2 yearsmax-age=63072000; includeSubDomains; preload
Content-Security-PolicyBlocks unauthorized scriptsStart restrictive, relax as needed
X-Content-Type-OptionsPrevents MIME sniffingnosniff
X-Frame-OptionsPrevents clickjackingDENY or SAMEORIGIN
Permissions-PolicyDisables browser featurescamera=(), microphone=(), geolocation=()
Referrer-PolicyControls referrer leakagestrict-origin-when-cross-origin
Cross-Origin-Opener-PolicyIsolates browsing contextsame-origin

#Step 2: Find leaked environment variables

Next.js inlines any env var prefixed with NEXT_PUBLIC_ into the client bundle. That's by design. The problem is when developers put API keys, database URLs, or secrets in NEXT_PUBLIC_ variables without realizing they're shipping those to every visitor.

bash
# Build your app
next build

# Search the output for secret-looking strings
grep -r "sk_live_" .next/static/
grep -r "DATABASE_URL" .next/static/
grep -r "SECRET" .next/static/

If any of those return matches, those values are in your client-side JavaScript. Anyone can view-source and find them.

The rule: only NEXT_PUBLIC_ vars should be in client code. Everything else — database URLs, API keys, JWT secrets, encryption keys — stays server-side only. Server components and API routes can access all env vars; client components can only see NEXT_PUBLIC_ ones.

Also check next.config.js for accidentally exposed config:

bash
grep -i "secret\|token\|password\|key" next.config.js

If you're hardcoding secrets in next.config.js, they'll be in the build output.

#Step 3: Verify CORS across API routes

Next.js API routes (app/api/ or pages/api/) don't have CORS configured by default. This means they're same-origin only — which is fine for most cases. But if you're building a public API or using fetch from a different origin, you need explicit CORS.

The dangerous pattern:

javascript
// app/api/data/route.js — DON'T DO THIS
import { NextResponse } from "next/server";

export async function GET() {
  return NextResponse.json({ data: "secret" }, {
    headers: {
      "Access-Control-Allow-Origin": "*",
      "Access-Control-Allow-Credentials": "true",
    },
  });
}

Wildcard origin with credentials enabled is a critical CORS vulnerability. Any attacker page can read authenticated responses from your API.

The fix:

javascript
// app/api/data/route.js — correct CORS
import { NextResponse } from "next/server";

const ALLOWED_ORIGINS = ["https://your-site.com", "https://app.your-site.com"];

export async function GET(request) {
  const origin = request.headers.get("origin");
  const allowed = ALLOWED_ORIGINS.includes(origin) ? origin : null;

  const response = NextResponse.json({ data: "secret" });

  if (allowed) {
    response.headers.set("Access-Control-Allow-Origin", allowed);
    response.headers.set("Access-Control-Allow-Credentials", "true");
  }

  return response;
}

For middleware-level CORS (all routes):

javascript
// middleware.js
import { NextResponse } from "next/server";

export function middleware(request) {
  const origin = request.headers.get("origin");
  const ALLOWED = ["https://your-site.com"];

  if (request.method === "OPTIONS") {
    const preflight = new NextResponse(null, { status: 204 });
    if (ALLOWED.includes(origin)) {
      preflight.headers.set("Access-Control-Allow-Origin", origin);
      preflight.headers.set("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
      preflight.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
      preflight.headers.set("Access-Control-Max-Age", "86400");
    }
    return preflight;
  }

  const response = NextResponse.next();
  if (ALLOWED.includes(origin)) {
    response.headers.set("Access-Control-Allow-Origin", origin);
    response.headers.set("Access-Control-Allow-Credentials", "true");
  }
  return response;
}

export const config = { matcher: "/api/:path*" };

#Step 4: Patch security headers in next.config.js

This is the single-file fix. Add this to your next.config.js (or next.config.mjs):

javascript
/** @type {import('next').NextConfig} */
const nextConfig = {
  headers: async () => [
    {
      source: "/(.*)",
      headers: [
        {
          key: "Strict-Transport-Security",
          value: "max-age=63072000; includeSubDomains; preload",
        },
        {
          key: "X-Content-Type-Options",
          value: "nosniff",
        },
        {
          key: "X-Frame-Options",
          value: "SAMEORIGIN",
        },
        {
          key: "Referrer-Policy",
          value: "strict-origin-when-cross-origin",
        },
        {
          key: "Permissions-Policy",
          value: "camera=(), microphone=(), geolocation=(), payment=(), usb=()",
        },
        {
          key: "Cross-Origin-Opener-Policy",
          value: "same-origin",
        },
        {
          key: "Cross-Origin-Resource-Policy",
          value: "same-origin",
        },
        {
          key: "Content-Security-Policy",
          value: [
            "default-src 'self'",
            "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
            "style-src 'self' 'unsafe-inline'",
            "img-src 'self' data: blob: https:",
            "font-src 'self'",
            "connect-src 'self' https://api.your-site.com",
            "frame-ancestors 'self'",
            "base-uri 'self'",
            "form-action 'self'",
          ].join("; "),
        },
      ],
    },
  ],
};

export default nextConfig;

Note on 'unsafe-inline': Next.js App Router requires 'unsafe-inline' in script-src for its inline flight/hydration scripts and in style-src for next/font inline styles. A nonce-based CSP is the better long-term solution, but this already blocks the vast majority of XSS attacks.

#The Vergate scan: headers in 10 seconds

Instead of manually running curl -sI against every route on every deploy, run a passive scan on vergate.dev/free-scan. It checks 20+ header security rules, probes for exposed files, detects CORS misconfigurations, and identifies server information leakage — all in about 10 seconds, no account required.

The scan catches things manual inspection misses: headers that vary by route, CORS policies on API subpaths, exposed staging endpoints, and technology-specific fingerprinting that reveals your stack version. Run it on every deploy, gate on a score, and the five-minute audit becomes a zero-minute habit.

Frequently asked questions

Does Next.js add security headers automatically?

No. Next.js ships with zero security headers by default. You must configure them in next.config.js headers() or via a custom server/middleware. Vercel adds HSTS by default on custom domains, but CSP, X-Frame-Options, and Permissions-Policy are entirely your responsibility.

What is the most critical missing header in Next.js apps?

Content-Security-Policy. Without CSP, any XSS vulnerability becomes a full account takeover because the browser loads attacker scripts without restriction. The second most critical is Strict-Transport-Security (HSTS), which prevents protocol downgrade attacks.

How do I check if my Next.js app leaks environment variables?

Search your built output (next build) for strings that match your server-side env var values. If a variable prefixed with SECRET_, KEY_, or PASSWORD appears in client bundles, it's leaking. Next.js automatically inlines variables prefixed with NEXT_PUBLIC_ into the client bundle — anything else should stay server-side only.

Can Vergate detect these issues automatically?

Yes. A 10-second passive scan checks 20+ security header rules, probes for exposed files, checks CORS configuration, and detects server info leakage. Run it free at vergate.dev/free-scan.

Keep reading