TL;DR
Almost every Next.js security problem is the server/client boundary being crossed by accident. The seven practices below take about 30 minutes. Keep secrets in server-only code, put authentication in every API route, treat Server Actions as public endpoints, and set your security headers.
"In Next.js, security is architecture. Know what runs where, and you'll know what's safe where."
Understanding the Server/Client Boundary 3 min
Next.js runs your code in two places. Most security problems here start with losing track of which place a given file is in.
| Code Location | Runs On | Can Access Secrets? |
|---|---|---|
| Server Components | Server only | Yes |
| API Routes / Route Handlers | Server only | Yes |
| Server Actions | Server only | Yes |
| Middleware | Edge/Server | Yes |
| Client Components ('use client') | Browser | No (only NEXT_PUBLIC_) |
Best Practice 1: Protect Environment Variables 2 min
Any variable prefixed NEXT_PUBLIC_ gets compiled into the JavaScript you ship. It's a publishing instruction, not a naming convention. Nothing secret goes in one:
# Server-side only (safe for secrets)
DATABASE_URL=postgresql://user:pass@host/db
STRIPE_SECRET_KEY=sk_live_xxx
JWT_SECRET=your-secret-key
# Client-side exposed (only public values)
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_APP_URL=https://yourdomain.com
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxx
Critical: Any NEXT_PUBLIC_ variable is bundled into client JavaScript and visible to users. Never put API keys, database URLs, or secrets in NEXT_PUBLIC_ variables.
When a scan turns up a live secret in a Next.js app, a NEXT_PUBLIC_ prefix is usually how it got there. The app works, nothing warns you, and the key ships in a file anyone can download.
Best Practice 2: Secure API Routes 5 min
Route handlers are public URLs. Anyone can call one with anything, so each needs to check who is asking and what they sent:
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { z } from 'zod';
const updateSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
});
export async function GET(request: NextRequest) {
// Check authentication
const session = await getServerSession();
if (!session?.user) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
// Return only user's own data
const userData = await db.user.findUnique({
where: { id: session.user.id },
select: { id: true, name: true, email: true }
});
return NextResponse.json(userData);
}
export async function PUT(request: NextRequest) {
const session = await getServerSession();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Validate input
const body = await request.json();
const result = updateSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', issues: result.error.issues },
{ status: 400 }
);
}
// Update user's own data only
await db.user.update({
where: { id: session.user.id },
data: result.data,
});
return NextResponse.json({ success: true });
}
Best Practice 3: Secure Server Actions 5 min
A Server Action looks like a function call in your component. It isn't. It compiles down to an HTTP endpoint that accepts whatever the caller sends:
'use server';
import { getServerSession } from 'next-auth';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
const postSchema = z.object({
title: z.string().min(3).max(200),
content: z.string().max(50000),
});
export async function createPost(formData: FormData) {
// Always verify authentication
const session = await getServerSession();
if (!session?.user) {
throw new Error('Unauthorized');
}
// Validate input
const rawData = {
title: formData.get('title'),
content: formData.get('content'),
};
const result = postSchema.safeParse(rawData);
if (!result.success) {
return { error: 'Invalid input' };
}
// Create with verified user ID
await db.post.create({
data: {
...result.data,
authorId: session.user.id, // Never trust client-provided user ID
},
});
revalidatePath('/posts');
return { success: true };
}
Important: Server Actions can be called directly by any client. Always validate authentication and never trust any data passed from the client.
Best Practice 4: Use Middleware for Route Protection 5 min
Middleware runs before the request reaches your route, which makes it the right place for redirects and a first pass at auth:
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getToken } from 'next-auth/jwt';
export async function middleware(request: NextRequest) {
const token = await getToken({ req: request });
const isAuthPage = request.nextUrl.pathname.startsWith('/login');
const isProtectedRoute = request.nextUrl.pathname.startsWith('/dashboard');
const isAdminRoute = request.nextUrl.pathname.startsWith('/admin');
// Redirect logged-in users away from auth pages
if (isAuthPage && token) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
// Protect dashboard routes
if (isProtectedRoute && !token) {
return NextResponse.redirect(new URL('/login', request.url));
}
// Admin routes require admin role
if (isAdminRoute) {
if (!token || token.role !== 'admin') {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*', '/admin/:path*', '/login'],
};
Best Practice 5: Add Security Headers 3 min
Headers are the cheapest thing on this list. Set them once in next.config.js and forget them:
/** @type {import('next').NextConfig} */
const nextConfig = {
async headers() {
return [
{
source: '/(.*)',
headers: [
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'X-Frame-Options',
value: 'DENY',
},
{
key: 'X-XSS-Protection',
value: '1; mode=block',
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin',
},
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=()',
},
],
},
];
},
};
module.exports = nextConfig;
Best Practice 6: Prevent Data Leaks in Server Components 3 min
Server Components read secrets safely. The leak happens on the way out, when a row you fetched gets handed to a Client Component as a prop and serialized into the page:
// app/dashboard/page.tsx
import { getServerSession } from 'next-auth';
import { redirect } from 'next/navigation';
export default async function DashboardPage() {
const session = await getServerSession();
if (!session) {
redirect('/login');
}
// Fetch only current user's data
const userData = await db.user.findUnique({
where: { id: session.user.id },
select: {
id: true,
name: true,
email: true,
// Never select: password, tokens, etc.
},
});
return (
<Dashboard user={userData} />
);
}
Best Practice 7: Secure File Uploads 5 min
Uploads need three checks before the file goes anywhere: who sent it, what it claims to be, and how big it is:
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
export async function POST(request: NextRequest) {
const session = await getServerSession();
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
// Validate file type
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json({ error: 'Invalid file type' }, { status: 400 });
}
// Validate file size
if (file.size > MAX_SIZE) {
return NextResponse.json({ error: 'File too large' }, { status: 400 });
}
// Generate safe filename
const ext = file.name.split('.').pop();
const safeFilename = `${crypto.randomUUID()}.${ext}`;
// Upload to secure storage (S3, etc.)
// ...
return NextResponse.json({ success: true, filename: safeFilename });
}
Common Next.js Security Mistakes
| Mistake | Impact | Prevention |
|---|---|---|
| Secrets in NEXT_PUBLIC_ vars | Credential exposure | Use server-only env vars |
| Unprotected API routes | Unauthorized access | Add auth to every route |
| Trusting Server Action input | Data manipulation | Always validate and verify auth |
| Exposing user data in props | Information disclosure | Select only needed fields |
| Missing security headers | XSS, clickjacking | Configure in next.config.js |
Official Resources: For the latest information, see Next.js Authentication Documentation, Server Actions Guide, and Security Headers Configuration.
Are Server Components secure by default?
Yes, for what they read. Secrets are safe inside a Server Component. The risk is on the way out: props handed to a Client Component get serialized into the page, so pass fields, not whole rows.
How do I protect API routes in App Router?
Call getServerSession, or your auth library, inside every route handler. A shared wrapper is worth writing early: the route people forget to protect is always the one added last.
Are Server Actions safe to use?
They're safe to use, but they aren't private. Any client can call one with any payload, so the action itself has to check the session and validate what it received.
Should I use Middleware or API route checks for auth?
Both. Middleware handles redirects and catches most unauthenticated traffic early. It can be bypassed, though, so the route still has to check for itself.
Further Reading
Put these practices into action with our step-by-step guides.
Verify Your Next.js Security
Scan your Next.js project for security issues and misconfigurations.