How to Audit a Next.js App for Security Misconfigurations and Header Leaks
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:
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:
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:
| Header | What it does | Value |
|---|---|---|
Strict-Transport-Security | Forces HTTPS for 2 years | max-age=63072000; includeSubDomains; preload |
Content-Security-Policy | Blocks unauthorized scripts | Start restrictive, relax as needed |
X-Content-Type-Options | Prevents MIME sniffing | nosniff |
X-Frame-Options | Prevents clickjacking | DENY or SAMEORIGIN |
Permissions-Policy | Disables browser features | camera=(), microphone=(), geolocation=() |
Referrer-Policy | Controls referrer leakage | strict-origin-when-cross-origin |
Cross-Origin-Opener-Policy | Isolates browsing context | same-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.
# 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:
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:
// 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:
// 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):
// 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):
/** @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.