Next.js has become a go-to React framework for production applications. Its hybrid rendering model—combining server components, client components, API routes, and middleware—delivers excellent performance and developer experience. However, this flexibility introduces security considerations that traditional React applications don't face. Understanding where your code runs, what data gets exposed to clients, and how to properly secure API endpoints is essential for building safe Next.js applications.
The Server-Client Boundary Challenge
Next.js 13+ introduced Server Components as the default, fundamentally changing how React applications work. Components can now run on the server, the client, or both—and understanding this boundary is critical for security.
When a Server Component renders, it executes on the server with full access to databases, file systems, and environment variables. The rendered HTML is sent to the client, but the component code itself never reaches the browser. This is powerful but requires careful attention to what data you include in the rendered output.
Client Components, marked with 'use client', are bundled and shipped to the browser. Any code in these components, including imports, becomes visible to anyone who inspects your JavaScript bundles. Sensitive logic or credentials here are exposed.
The danger arises at the boundary: data passed from Server Components to Client Components via props must be treated as public. If you pass a full user object containing sensitive fields, those fields will be visible in the client-side hydration data.
Server Components Security
Server Components offer security advantages when used correctly. They can directly access databases and APIs without exposing credentials or implementation details to clients. However, you must be careful about what ends up in the rendered output.
Safe Data Fetching
Fetch data and filter sensitive fields before rendering:
// app/users/[id]/page.tsx
import { db } from '@/lib/db'
import { UserProfile } from './user-profile'
async function getUser(id: string) {
const user = await db.user.findUnique({
where: { id },
select: {
id: true,
name: true,
avatar: true,
bio: true,
// Explicitly exclude: passwordHash, email, ssn, etc.
}
})
return user
}
export default async function UserPage({ params }: { params: { id: string } }) {
const user = await getUser(params.id)
if (!user) {
notFound()
}
// Only safe, public data reaches the client
return <UserProfile user={user} />
}Preventing Data Leaks
Use TypeScript to enforce what data can be passed to client components:
// types/user.ts
export interface PublicUser {
id: string
name: string
avatar: string | null
}
export interface PrivateUser extends PublicUser {
email: string
passwordHash: string
ssn?: string
}
// utils/sanitize.ts
export function toPublicUser(user: PrivateUser): PublicUser {
return {
id: user.id,
name: user.name,
avatar: user.avatar,
}
}Server-Only Code
Mark code that should never run on the client:
// lib/db.ts
import 'server-only'
import { PrismaClient } from '@prisma/client'
export const db = new PrismaClient()The server-only package throws a build error if this module is imported into a Client Component, preventing accidental exposure of server-side code.
API Route Protection
Next.js API routes (both Pages Router /pages/api and App Router /app/api) require explicit security measures. Unlike Server Components, API routes are direct HTTP endpoints that anyone can call.
Authentication Middleware
Implement authentication checks consistently:
// lib/auth.ts
import { cookies } from 'next/headers'
import { verifyToken } from './jwt'
export async function getAuthenticatedUser() {
const cookieStore = cookies()
const token = cookieStore.get('session')?.value
if (!token) {
return null
}
try {
const payload = await verifyToken(token)
return payload.user
} catch {
return null
}
}
export async function requireAuth() {
const user = await getAuthenticatedUser()
if (!user) {
throw new Error('Unauthorized')
}
return user
}Protected API Route Pattern
// app/api/admin/users/route.ts
import { NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth'
import { db } from '@/lib/db'
export async function GET() {
try {
const user = await requireAuth()
if (user.role !== 'admin') {
return NextResponse.json(
{ error: 'Forbidden' },
{ status: 403 }
)
}
const users = await db.user.findMany({
select: { id: true, name: true, email: true, role: true }
})
return NextResponse.json(users)
} catch (error) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
}Input Validation
Validate all inputs in API routes:
// app/api/posts/route.ts
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { requireAuth } from '@/lib/auth'
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1).max(50000),
published: z.boolean().default(false),
})
export async function POST(request: Request) {
try {
const user = await requireAuth()
const body = await request.json()
const validatedData = CreatePostSchema.parse(body)
const post = await db.post.create({
data: {
...validatedData,
authorId: user.id,
},
})
return NextResponse.json(post, { status: 201 })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation failed', details: error.errors },
{ status: 400 }
)
}
throw error
}
}Rate Limiting
Protect API routes from abuse:
// lib/rate-limit.ts
import { LRUCache } from 'lru-cache'
const rateLimitCache = new LRUCache<string, number[]>({
max: 10000,
ttl: 60 * 1000, // 1 minute
})
export function rateLimit(ip: string, limit: number = 10): boolean {
const now = Date.now()
const windowStart = now - 60 * 1000
const requests = rateLimitCache.get(ip) || []
const recentRequests = requests.filter(time => time > windowStart)
if (recentRequests.length >= limit) {
return false
}
recentRequests.push(now)
rateLimitCache.set(ip, recentRequests)
return true
}
// Usage in API route
export async function POST(request: Request) {
const ip = request.headers.get('x-forwarded-for') || 'unknown'
if (!rateLimit(ip, 5)) {
return NextResponse.json(
{ error: 'Too many requests' },
{ status: 429 }
)
}
// Handle request
}Environment Variable Handling
Next.js environment variables require careful handling. The framework distinguishes between server-only and public variables, but misconfigurations can expose secrets.
Understanding the Prefix Convention
NEXT_PUBLIC_*variables are embedded in client bundles—anyone can see them- All other variables are server-only and never reach the client
# .env.local
# Server-only - never exposed to client
DATABASE_URL="postgresql://..."
JWT_SECRET="your-secret-key"
STRIPE_SECRET_KEY="sk_live_..."
# Public - embedded in client JavaScript
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_live_..."
NEXT_PUBLIC_API_URL="https://api.example.com"Common Mistakes to Avoid
Never expose secrets by adding the NEXT_PUBLIC_ prefix:
// DANGEROUS - This exposes your secret to the client
const apiKey = process.env.NEXT_PUBLIC_API_SECRET
// CORRECT - Keep secrets server-side only
const apiKey = process.env.API_SECRET // Only accessible in Server Components/API routesRuntime Environment Validation
Validate required environment variables at build time:
// lib/env.ts
import { z } from 'zod'
const envSchema = z.object({
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
NEXT_PUBLIC_API_URL: z.string().url(),
})
export const env = envSchema.parse({
DATABASE_URL: process.env.DATABASE_URL,
JWT_SECRET: process.env.JWT_SECRET,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
})Middleware Security
Next.js Middleware runs on the Edge, intercepting requests before they reach your pages or API routes. Use it for authentication, but understand its limitations.
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { verifyToken } from './lib/edge-auth'
const protectedPaths = ['/dashboard', '/api/admin', '/settings']
const publicPaths = ['/login', '/signup', '/api/auth']
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// Skip public paths
if (publicPaths.some(path => pathname.startsWith(path))) {
return NextResponse.next()
}
// Check protected paths
if (protectedPaths.some(path => pathname.startsWith(path))) {
const token = request.cookies.get('session')?.value
if (!token) {
return NextResponse.redirect(new URL('/login', request.url))
}
try {
await verifyToken(token)
return NextResponse.next()
} catch {
const response = NextResponse.redirect(new URL('/login', request.url))
response.cookies.delete('session')
return response
}
}
return NextResponse.next()
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}Security Headers
Configure security headers in next.config.js:
// next.config.js
const securityHeaders = [
{
key: 'X-DNS-Prefetch-Control',
value: 'on'
},
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload'
},
{
key: 'X-Frame-Options',
value: 'SAMEORIGIN'
},
{
key: 'X-Content-Type-Options',
value: 'nosniff'
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin'
},
{
key: 'Content-Security-Policy',
value: "default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline';"
}
]
module.exports = {
async headers() {
return [
{
source: '/:path*',
headers: securityHeaders,
},
]
},
}Why Traditional Pentesting Falls Short
Next.js applications present unique challenges for traditional penetration testing. The framework's hybrid rendering, automatic code splitting, and server/client boundaries require testers to understand React and Next.js internals. Manual testers must trace data flow between Server Components and Client Components, identify what's exposed in hydration data, and understand the middleware execution model.
Traditional assessments are also point-in-time, while Next.js applications often deploy multiple times per day. By the time a pentest report arrives, the API routes may have changed, new Server Actions might have been added, and the attack surface has evolved.
How Agentic Testing Secures Next.js Applications
RedVeil's AI-powered penetration testing is designed for modern application architectures like Next.js. RedVeil's agents autonomously discover your API routes, test authentication and authorization boundaries, and identify data exposure issues in your application.
RedVeil doesn't just scan for known vulnerabilities—it reasons through your application's logic, identifying where sensitive data might leak through props, where API routes lack proper authentication, and where input validation gaps could lead to injection attacks.
Each finding comes with validated proof-of-concept evidence, clear reproduction steps, and remediation guidance specific to Next.js patterns. With on-demand testing, you can verify your security after each deployment without waiting weeks for manual assessments.
Conclusion
Next.js security requires understanding the framework's unique architecture. The server-client boundary, environment variable handling, and API route protection all demand careful attention. Server Components can be powerful security tools when used correctly, keeping sensitive logic and data away from client bundles.
Traditional security testing struggles to keep pace with Next.js development. On-demand, agentic penetration testing from RedVeil provides the autonomous, intelligent assessment modern Next.js applications need.
Start securing your Next.js application with RedVeil at https://app.redveil.ai/ and protect your application against real-world attacks.