Next.js Security Guide 2026: App Router, API Routes, and Beyond
Complete Next.js security guide for 2026 — Server Actions, middleware, env vars, auth patterns, and API route hardening. Beyond just headers.
Rod
Founder & Developer
Next.js security isn't just about setting the right headers — though that matters too. The App Router introduced Server Actions, a new way to run server-side code that has its own security considerations. Middleware changed how auth works at the edge. And the NEXT_PUBLIC_ prefix has confused enough developers into exposing secrets that it deserves its own section.
This is the comprehensive guide. Different from the security headers post that focuses only on HTTP response headers — this one covers the full security surface of a Next.js 14+ app: Server Actions, API routes, middleware, environment variables, CORS, auth patterns, and dependency hygiene.
We've scanned hundreds of Next.js repos. The patterns below fix the issues we see most.
Environment Variables: What's Public and What Isn't
Start here because it's the mistake with the highest blast radius.
Next.js has two types of environment variables:
- Without prefix (
DATABASE_URL,STRIPE_SECRET_KEY): Server-only. These are never included in client-side bundles. Only accessible in Server Components, API routes, Server Actions, andnext.config.ts. - With
NEXT_PUBLIC_prefix (NEXT_PUBLIC_SUPABASE_URL): Bundled into client JavaScript. Visible to anyone who opens your app and inspects the source.
// BAD: secret key with NEXT_PUBLIC_ prefix — now it's in the browser bundle
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_abc123
// GOOD: secret key without prefix — server-only
STRIPE_SECRET_KEY=sk_live_abc123
// GOOD: anon key with NEXT_PUBLIC_ prefix — designed to be public
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGc...The Supabase anon key is designed to be public — it's restricted by Row Level Security policies. The Supabase service role key bypasses RLS entirely. It must never have NEXT_PUBLIC_ prefix and must never appear in any client-side code.
Run a free env variable scan to see what's actually exposed in your project: Data Hogo env check →
Server Actions Security
Server Actions are functions marked with "use server" that run on the server. They're called from client components via form submissions or JavaScript fetch calls. The security-relevant fact: they're HTTP POST endpoints. They're reachable from any origin with the right request format.
Authentication in Server Actions
Every Server Action that touches user data must verify the user's identity. Don't assume that because the action is "server-side" it's only callable from your own UI.
"use server";
import { createClient } from "@/lib/supabase/server";
// BAD: no auth check — any request can trigger this
export async function deletePost(postId: string) {
await db.posts.delete({ where: { id: postId } });
}
// GOOD: verify auth first, then verify ownership
export async function deletePost(postId: string) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
throw new Error("Unauthorized");
}
// Verify the post belongs to this user before deleting
const post = await db.posts.findUnique({
where: { id: postId, userId: user.id }, // ownership check
});
if (!post) {
throw new Error("Not found");
}
await db.posts.delete({ where: { id: postId } });
}Input Validation in Server Actions
AI tools generate Server Actions without input validation. A user submitting unexpected data types, missing fields, or oversized payloads can crash your action or worse.
"use server";
import { z } from "zod";
const createPostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1).max(50000),
// status must be one of these values — user can't set arbitrary status
status: z.enum(["draft", "published"]),
});
export async function createPost(formData: FormData) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error("Unauthorized");
const parsed = createPostSchema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
status: formData.get("status"),
});
if (!parsed.success) {
return { error: parsed.error.flatten() };
}
await supabase.from("posts").insert({
...parsed.data,
user_id: user.id, // set server-side, never from input
});
}API Route Security
Every API route is a public HTTP endpoint. Even if your frontend doesn't link to it directly, it's reachable. Write every route with that assumption.
Authentication Pattern
// app/api/repos/route.ts
import { createClient } from "@/lib/supabase/server";
import { NextResponse } from "next/server";
export async function GET() {
const supabase = await createClient();
// ALWAYS use getUser(), never getSession() for server-side auth
// getSession() reads from cookies without verification — can be spoofed
const { data: { user }, error } = await supabase.auth.getUser();
if (error || !user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { data } = await supabase
.from("repositories")
.select("*")
// RLS handles the filtering — user only sees their own repos
.order("created_at", { ascending: false });
return NextResponse.json(data);
}Input Validation Pattern
import { z } from "zod";
const scanSchema = z.object({
repositoryId: z.string().uuid(),
appUrl: z.string().url().optional(),
});
export async function POST(req: Request) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await req.json().catch(() => null);
if (!body) return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
const parsed = scanSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
// parsed.data is type-safe and validated
const { repositoryId, appUrl } = parsed.data;
// ... rest of logic
}CORS Configuration
Next.js doesn't restrict CORS by default for API routes. If your API should only be called from your own frontend, add CORS headers explicitly.
// lib/cors.ts
export function corsHeaders(origin: string | null) {
const allowedOrigins = [
"https://datahogo.com",
"https://www.datahogo.com",
process.env.NODE_ENV === "development" ? "http://localhost:3000" : null,
].filter(Boolean);
const isAllowed = origin && allowedOrigins.includes(origin);
return {
"Access-Control-Allow-Origin": isAllowed ? origin : allowedOrigins[0],
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};
}
// In your API route
export async function OPTIONS(req: Request) {
const origin = req.headers.get("origin");
return new Response(null, { headers: corsHeaders(origin), status: 200 });
}Middleware: What It Can and Can't Do
Next.js middleware runs at the Edge before your route handler. It's the right place for:
- Redirecting unauthenticated users to the login page
- Rate limiting by IP address
- Locale detection and routing
It's not the right place to rely on for authorization. Middleware doesn't have access to your database. It can check if a session cookie exists, but not if that session has permission to access a specific resource.
// middleware.ts
import { createServerClient } from "@supabase/ssr";
import { NextRequest, NextResponse } from "next/server";
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Public paths that don't need auth
const publicPaths = ["/", "/login", "/register", "/api/webhooks"];
if (publicPaths.some((p) => pathname.startsWith(p))) {
return NextResponse.next();
}
// Check for session — redirect to login if missing
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ cookies: { /* cookie handlers */ } }
);
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};Important: middleware uses
getSession()here because it's for a redirect check, not data access. Your API routes and Server Actions should still callgetUser()for definitive auth verification.
Security Headers
Set these in next.config.ts. If you only have time for one fix on this list, do this one — it's a single file change that addresses browser-level attack vectors.
// next.config.ts
const securityHeaders = [
{ key: "X-DNS-Prefetch-Control", value: "on" },
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
},
{
key: "Content-Security-Policy",
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'", // adjust for your CDNs
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self' https://*.supabase.co",
].join("; "),
},
];
const nextConfig = {
async headers() {
return [{ source: "/(.*)", headers: securityHeaders }];
},
};
export default nextConfig;The security headers guide goes deeper on each header and explains how to tune CSP for Stripe, PostHog, Google Fonts, and other common third parties. Check your current header score free at Data Hogo's header checker.
Dependency Security
Your package.json is part of your attack surface. AI tools suggest packages without checking their CVE status. A package that was safe when Cursor suggested it might have a published exploit today.
# Check for known vulnerabilities
npm audit
# Fix what can be fixed automatically
npm audit fix
# Check for outdated packages (not the same as CVEs, but related)
npm outdatedRun npm audit before every production deployment. Make it part of your CI pipeline:
# .github/workflows/security.yml
- name: Dependency audit
run: npm audit --audit-level=high
# Fails CI if high or critical CVEs existThe Quick Audit Checklist
Run through this before your next deployment:
[ ] NEXT_PUBLIC_ prefix only on variables designed to be public
[ ] STRIPE_SECRET_KEY, SUPABASE_SERVICE_ROLE_KEY — no NEXT_PUBLIC_ prefix
[ ] Every API route calls getUser() as first step
[ ] Every Server Action calls getUser() as first step
[ ] Zod validation on every API route and Server Action that accepts input
[ ] Security headers set in next.config.ts
[ ] npm audit shows zero high/critical findings
[ ] RLS enabled on all Supabase tables
[ ] Service role key only used in server-side code with explicit justification
[ ] CORS configured for API routes that should be frontend-onlyScan Your Next.js App
The checklist identifies patterns. A scan finds the specific instances in your specific codebase — the exact file, the exact line, with a plain-English explanation of why it's a problem.
Data Hogo scans Next.js repos specifically, covering all the areas above plus your deployed URL's headers and your Supabase RLS configuration.
Frequently Asked Questions
Are Next.js Server Actions secure?
Server Actions run on the server, which is an advantage — the business logic isn't exposed to the client. However, they are reachable via HTTP POST requests from any origin by default. You need to validate authentication inside every Server Action, validate all inputs with Zod, and verify that the acting user has permission to perform the action. Never assume a Server Action is only reachable from your own UI.
How do I protect Next.js API routes from unauthorized access?
Every API route should verify authentication as the first thing it does. With Supabase Auth, call supabase.auth.getUser() and return a 401 if no user is found. Never use getSession() for server-side auth checks — it reads from cookies without verification and can be spoofed. Add input validation with Zod on every route that accepts a request body. Add rate limiting in middleware for public-facing endpoints.
What environment variables in Next.js are exposed to the browser?
Any environment variable prefixed with NEXT_PUBLIC_ is bundled into the client-side JavaScript and visible to anyone who opens your app. This is intentional for things like your Supabase anon key or PostHog project key. Secret keys must never have the NEXT_PUBLIC_ prefix — they should only be accessed in Server Components, API routes, and Server Actions.
Does Next.js middleware provide security protection?
Next.js middleware runs at the Edge before the request reaches your app. It's a good place for authentication checks, rate limiting, and redirecting unauthenticated users. However, middleware alone isn't sufficient for authorization — it doesn't know what specific resource the user is accessing. You still need server-side auth checks in your API routes and Server Actions. Treat middleware as the first line of defense, not the only one.
How do I add Content Security Policy to a Next.js app?
Set the Content-Security-Policy header in the headers() function in next.config.ts. The minimum effective CSP for most Next.js apps: default-src 'self', script-src 'self' 'unsafe-inline' (needed for Next.js's inline scripts), style-src 'self' 'unsafe-inline', img-src 'self' data: https:. Test with Report-Only mode before enforcing.
Next.js gives you the tools to build secure apps. The framework doesn't make those choices for you. Authentication, input validation, CORS, and proper env var handling are decisions you make once per project. Get them right and you remove the entire class of attacks that take down AI-generated apps in production.
Related Posts
Stripe Webhook Security: The Checklist Your App Needs
Stripe webhook security checklist for Next.js developers. Signature verification, replay attack prevention, idempotency, and error handling with code examples.
Prisma + Supabase Security: The Risks Most Guides Skip
Raw queries, RLS bypass risks, connection string exposure, and migration safety in Prisma + Supabase apps. A practical security guide with code examples.
Docker Security for Developers: The Practical Guide (2026)
Docker security best practices for application developers — not DevOps. Running containers as non-root, managing secrets, picking safe base images, and more.