
The Next.js Security Checklist: 15 Vulnerabilities to Fix Before You Ship
The Next.js Security Checklist: 15 Vulnerabilities to Fix Before You Ship
Next.js has become the default framework for production React applications. Vercel makes deployment trivial. Server Actions make backend logic feel like calling a function. And that simplicity is exactly where things go wrong.
We scan hundreds of Next.js apps through PreBreach and see the same vulnerabilities repeated across codebases of every size. This is the checklist we wish every Next.js developer had before their first deploy.
Each item includes what to check, why it matters, and how to fix it with working code.
Server Actions
Server Actions are the most misunderstood attack surface in modern Next.js. They look like simple function calls in your components, but every Server Action is a publicly accessible HTTP POST endpoint.
1. Server Actions Are Public HTTP Endpoints
What to check: Look for any "use server" function that performs a sensitive operation without explicit authentication or authorization checks.
The risk: When you define a Server Action, Next.js creates a POST endpoint accessible to anyone who knows (or discovers) the action ID. The framework does not gate these behind authentication. If your action deletes a user account, updates a database row, or sends an email, an attacker can invoke it directly without ever loading your UI.
The fix: Validate the session at the top of every Server Action. Do not rely on the calling component to check auth.
"use server";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
export async function deleteAccount() {
const session = await auth();
if (!session?.user?.id) {
redirect("/login");
}
// Now safe to proceed
await db.user.delete({ where: { id: session.user.id } });
}
Treat every Server Action the way you would treat a REST API endpoint: validate who is calling it and what they are allowed to do.
2. Missing Authorization Checks on Server Actions
What to check: Server Actions that accept an ID parameter and perform operations on that resource without verifying the current user owns or has access to that resource.
The risk: Even if you check that a user is authenticated, you still need to verify they are authorized to perform the specific action. An authenticated user passing someone else's projectId into a Server Action can modify or delete resources they do not own. This is a classic Insecure Direct Object Reference (IDOR) vulnerability.
The fix: Always verify ownership or role-based access before operating on a resource.
"use server";
import { auth } from "@/lib/auth";
export async function updateProject(projectId: string, data: FormData) {
const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized");
// Verify ownership - don't just trust the projectId
const project = await db.project.findUnique({
where: { id: projectId },
});
if (!project || project.ownerId !== session.user.id) {
throw new Error("Forbidden");
}
await db.project.update({
where: { id: projectId },
data: { name: data.get("name") as string },
});
}
3. No CSRF Protection on Custom Form Handlers
What to check: If you are building custom form handlers or API routes that mutate data using cookies for authentication, check whether you have any CSRF protection in place.
The risk: Next.js Server Actions include built-in CSRF protection through origin checking. But if you are using API routes (route.ts) with cookie-based auth for form submissions, you do not get that protection automatically. An attacker can host a page that submits a form to your API endpoint, and the browser will attach your user's cookies.
The fix: For API routes handling mutations with cookie-based auth, verify the Origin or Referer header, or implement a CSRF token pattern.
// app/api/settings/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const origin = request.headers.get("origin");
const allowedOrigins = [
process.env.NEXT_PUBLIC_APP_URL,
"https://yourdomain.com",
];
if (!origin || !allowedOrigins.includes(origin)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// Proceed with the mutation
}
For Server Actions specifically, Next.js handles this for you. But do not mix patterns without understanding the differences.
Middleware
Middleware runs before every matched request and is the natural place for auth checks, redirects, and header injection. It is also one of the most dangerous single points of failure.
4. CVE-2025-29927: Middleware Authorization Bypass
What to check: If you are running Next.js versions prior to 14.2.25 (v14) or 15.2.3 (v15), your middleware can be bypassed entirely.
The risk: CVE-2025-29927 allows an attacker to add a specific header (x-middleware-subrequest) to their request that causes Next.js to skip middleware execution. If your authentication or authorization logic lives in middleware, an attacker can bypass it completely with a single header. This is a critical severity vulnerability.
The fix: Update Next.js immediately.
npm install next@latest
If you cannot update immediately, add a WAF rule or edge-level check to block requests containing the x-middleware-subrequest header from external sources.
// Temporary mitigation in middleware.ts if you can't update
import { NextRequest, NextResponse } from "next/server";
export function middleware(request: NextRequest) {
// Block the bypass header from external requests
if (request.headers.get("x-middleware-subrequest")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// Your normal middleware logic
}
But seriously, just update.
5. Auth Logic That Only Lives in Middleware
What to check: Does your application enforce authentication exclusively in middleware, with no secondary checks in Server Actions, API routes, or data-fetching layers?
The risk: Middleware is a single layer. If it fails, gets bypassed (see CVE above), or has a logic error in its matcher config, every protected route becomes publicly accessible. Middleware matchers are regex-based, and a missed path can silently expose entire sections of your app.
The fix: Use middleware as a first line of defense, not the only line. Add auth checks in your data layer or at the top of each Server Action and API route.
// middleware.ts - first layer
export function middleware(request: NextRequest) {
const token = request.cookies.get("session");
if (!token && request.nextUrl.pathname.startsWith("/dashboard")) {
return NextResponse.redirect(new URL("/login", request.url));
}
}
export const config = {
matcher: ["/dashboard/:path*", "/api/protected/:path*"],
};
// lib/auth-guard.ts - second layer, used in Server Actions and API routes
export async function requireAuth() {
const session = await auth();
if (!session?.user) {
throw new Error("Authentication required");
}
return session;
}
Defense in depth is not paranoia. It is how you survive the vulnerability you have not discovered yet.
API Routes
Next.js API routes (app/api/**/route.ts) are fully exposed HTTP endpoints. They need the same security discipline as any backend API.
6. No Rate Limiting on API Routes
What to check: Do any of your API routes accept requests without any rate limiting? Pay special attention to authentication endpoints, form submissions, AI/LLM proxy routes, and any endpoint that triggers email sends or database writes.
The risk: Without rate limiting, an attacker can brute-force login endpoints, trigger thousands of password reset emails, exhaust your AI API credits, or DDoS your database. This is consistently one of the most common issues we find in Next.js apps deployed on Vercel.
The fix: Use Vercel's built-in rate limiting, Upstash Redis, or an edge-level WAF. Here is a lightweight approach with Upstash:
// lib/rate-limit.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, "60 s"), // 10 requests per minute
});
export async function checkRateLimit(identifier: string) {
const { success, limit, remaining, reset } = await ratelimit.limit(identifier);
return { success, limit, remaining, reset };
}
// app/api/auth/login/route.ts
import { checkRateLimit } from "@/lib/rate-limit";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
const { success } = await checkRateLimit(`login:${ip}`);
if (!success) {
return NextResponse.json(
{ error: "Too many requests" },
{ status: 429 }
);
}
// Process login
}
7. Missing Input Validation on API Routes
What to check: Are you parsing request bodies with request.json() and passing the result directly to database queries or external APIs without validation?
The risk: Unvalidated input is the root cause of injection attacks, type confusion bugs, and unexpected application behavior. Even if your database ORM provides some protection, relying on it as your only safeguard is brittle.
The fix: Use Zod to validate every input at the API boundary.
// app/api/projects/route.ts
import { z } from "zod";
import { NextRequest, NextResponse } from "next/server";
const createProjectSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
isPublic: z.boolean().default(false),
});
export async function POST(request: NextRequest) {
const body = await request.json();
const result = createProjectSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ error: "Invalid input", details: result.error.flatten() },
{ status: 400 }
);
}
// result.data is now typed and validated
const project = await db.project.create({ data: result.data });
return NextResponse.json(project, { status: 201 });
}
This is five minutes of work that prevents entire categories of vulnerabilities.
8. Exposed Error Details in Production
What to check: Do your API routes return raw error messages, stack traces, or internal system details in error responses?
The risk: Detailed error messages leak information about your tech stack, database structure, file paths, and internal logic. Attackers use this information to craft targeted exploits. A Prisma error message, for example, can reveal your exact database schema.
The fix: Catch all errors and return generic messages in production. Log the details server-side.
// app/api/projects/[id]/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const project = await db.project.findUniqueOrThrow({
where: { id: params.id },
});
return NextResponse.json(project);
} catch (error) {
// Log full error server-side
console.error("Failed to fetch project:", error);
// Return generic message to client
return NextResponse.json(
{ error: "An unexpected error occurred" },
{ status: 500 }
);
}
}
Never let error.message reach the client in production. It is free reconnaissance for attackers.
Environment Variables
The NEXT_PUBLIC_ prefix system is elegant for developer experience and dangerous when misunderstood.
9. Secrets Leaked via NEXT_PUBLIC_ Prefix
What to check: Audit every environment variable starting with NEXT_PUBLIC_. Each one is embedded in the client-side JavaScript bundle and visible to anyone who opens browser DevTools.
The risk: We routinely find NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY, NEXT_PUBLIC_STRIPE_SECRET_KEY, and NEXT_PUBLIC_DATABASE_URL in production bundles. The NEXT_PUBLIC_ prefix tells Next.js to inline the variable into the browser bundle at build time. Anything with this prefix is public.
The fix: Audit your .env files. Only the following types of values should ever use NEXT_PUBLIC_:
- Supabase anon key (not the service role key)
- Publishable Stripe key (not the secret key)
- Public API URLs
- Feature flags
- Analytics IDs
# .env.local - CORRECT
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGci... # Public anon key - safe
SUPABASE_SERVICE_ROLE_KEY=eyJhbGci... # No prefix - server only
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_... # Publishable - safe
STRIPE_SECRET_KEY=sk_live_... # No prefix - server only
Run this grep on your codebase to find potential leaks:
grep -r "NEXT_PUBLIC_" .env* | grep -iE "secret|private|service_role|password|token"
If that returns anything, you have a problem.
10. .env Files Committed to Git
What to check: Run git log --all --full-history -- "*.env*" to see if any .env files have ever been committed to your repository.
The risk: Even if you have added .env to .gitignore now, if it was ever committed, the secrets are in your git history permanently. Anyone with repository access (or access to a leaked repo) can extract them.
The fix: First, add all env files to .gitignore:
# .gitignore
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.env*.local
If secrets were previously committed, rotating them is the only safe remediation. Rewriting git history removes the file from future clones, but anyone who already pulled the repo has the values. Rotate every secret that was exposed.
Client-Side Vulnerabilities
The browser is hostile territory. Everything your client-side code does is visible and manipulable.
11. Source Maps Enabled in Production
What to check: Open your production site in a browser, go to DevTools > Sources. Can you see your original TypeScript/JSX source code with readable variable names and comments?
The risk: Production source maps expose your entire application source code, including business logic, API integration patterns, auth flows, and internal comments that may reference security assumptions. This is a free codebase dump for any attacker.
The fix: Disable source maps in your Next.js config:
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
productionBrowserSourceMaps: false, // This is the default, but be explicit
};
module.exports = nextConfig;
If productionBrowserSourceMaps is set to true anywhere in your config, remove it. Also check if your CI/CD pipeline or hosting provider is overriding this setting.
12. NEXT_DATA Leaking Server-Side Data
What to check: View the source of any server-rendered page and search for __NEXT_DATA__. Examine the JSON blob to see what data is being passed from the server to the client.
The risk: In the Pages Router, getServerSideProps passes its entire return value to the client via __NEXT_DATA__. If your server-side function fetches a user object with sensitive fields (email, internal IDs, roles, subscription status) and passes the whole object as props, all of that data is in the HTML source. Even with the App Router, be careful about what your Server Components pass to Client Components as props.
The fix: Only pass the exact fields the client needs. Never pass entire database objects.
// BAD - leaks everything to client
export async function getServerSideProps(context) {
const user = await db.user.findUnique({ where: { id: userId } });
return { props: { user } }; // Entire user object including hashedPassword, internalNotes, etc.
}
// GOOD - only pass what the UI needs
export async function getServerSideProps(context) {
const user = await db.user.findUnique({
where: { id: userId },
select: { name: true, avatarUrl: true },
});
return { props: { user } };
}
In the App Router, the same principle applies when passing props from Server Components to Client Components.
13. Using dangerouslySetInnerHTML Without Sanitization
What to check: Search your codebase for dangerouslySetInnerHTML. Trace the data source for each usage. Is the HTML user-generated or derived from user input at any point?
The risk: dangerouslySetInnerHTML injects raw HTML into the DOM. If that HTML contains user-controlled content, you have a stored XSS vulnerability. An attacker can inject <script> tags, event handlers, or any other executable HTML.
The fix: If you must render HTML (for markdown content, rich text editors, CMS output), sanitize it server-side.
import DOMPurify from "isomorphic-dompurify";
function BlogPost({ htmlContent }: { htmlContent: string }) {
const sanitized = DOMPurify.sanitize(htmlContent, {
ALLOWED_TAGS: ["p", "br", "strong", "em", "a", "ul", "ol", "li", "h2", "h3", "code", "pre"],
ALLOWED_ATTR: ["href", "target", "rel"],
});
return <div dangerouslySetInnerHTML={{ __html: sanitized }} />;
}
Better yet, use a markdown renderer that does not produce raw HTML, or use React's built-in JSX escaping by avoiding dangerouslySetInnerHTML entirely.
Security Headers
Headers are your last line of defense and the easiest to configure. Missing headers are also the lowest-hanging fruit for any scanner.
14. Missing Content Security Policy (CSP)
What to check: Open DevTools > Network, click on the document request, and look for the Content-Security-Policy header. If it is missing or set to a permissive policy, your app is vulnerable to XSS exploitation.
The risk: Without a CSP, any XSS vulnerability can load external scripts, exfiltrate data, or hijack user sessions. A CSP acts as a containment policy: even if an attacker finds an injection point, the browser blocks unauthorized script execution.
The fix: Add a CSP header in your Next.js config or middleware. Start strict and loosen as needed:
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const response = NextResponse.next();
// Build your CSP
const csp = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'", // Tighten with nonces in production
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self' https://*.supabase.co https://*.vercel-insights.com",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
].join("; ");
response.headers.set("Content-Security-Policy", csp);
return response;
}
The ideal CSP uses nonces instead of 'unsafe-inline'. Next.js supports this with the nonce prop on <Script> tags and the experimental.sri config option.
15. Missing HSTS and Other Standard Headers
What to check: Scan your response headers for Strict-Transport-Security, X-Content-Type-Options, X-Frame-Options, and Referrer-Policy. Use a tool like securityheaders.com for a quick check.
The risk: Without HSTS, a user's first visit to your site can be intercepted via an HTTP downgrade attack (SSL stripping). Without X-Content-Type-Options, browsers may MIME-sniff responses into executable content. Without X-Frame-Options or frame-ancestors in your CSP, your site can be embedded in an iframe for clickjacking attacks.
The fix: Set all standard security headers in next.config.js:
// next.config.js
/** @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: "DENY",
},
{
key: "Referrer-Policy",
value: "strict-origin-when-cross-origin",
},
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=()",
},
{
key: "X-DNS-Prefetch-Control",
value: "on",
},
],
},
],
};
module.exports = nextConfig;
On Vercel, HTTPS is enforced at the edge, but HSTS tells browsers to never attempt an HTTP connection. It costs nothing to configure and eliminates an entire class of attacks.
How to Use This Checklist
Go through each item in order. Most of these take minutes to fix. Here is a prioritized approach:
- Patch first: Update Next.js to close CVE-2025-29927 (#4)
- Audit secrets: Check
NEXT_PUBLIC_variables (#9) and git history (#10) - Add auth checks: Server Actions (#1, #2) and API routes (#6, #7)
- Harden output: Error messages (#8), source maps (#11),
__NEXT_DATA__(#12) - Set headers: CSP (#14), HSTS and friends (#15)
- Fix remaining: CSRF (#3), middleware depth (#5), XSS (#13)
Automate the Check
Manually auditing is useful once, but you need continuous monitoring as your codebase evolves. Every deploy can introduce a new Server Action without auth, a new NEXT_PUBLIC_ secret, or a regression in your security headers.
PreBreach scans your deployed Next.js application for all 15 of these issues (and more) in 30-60 minutes. Our scanner includes 24 Nuclei templates built specifically for modern stacks including Next.js, Vercel, Supabase, and Clerk. Each finding includes a CVSS v4.0 severity score and AI-generated remediation guidance specific to your codebase.
