Complete Next.js security guide 2025: authentication, API protection & best practices
Master Next.js security with advanced authentication, API protection, CSRF/XSS prevention, middleware security, and production-ready best practices.

Critical Security Stats
In 2025, 73% of security breaches target web applications, making Next.js security your first line of defense against increasingly sophisticated cyber threats. The average cost of a data breach now exceeds $4.5 million.
While other frameworks leave you vulnerable to common attacks, Next.js provides powerful built-in security features – but only when you know how to use them correctly.
This comprehensive guide will teach you to implement bulletproof security that goes beyond basic tutorials. You'll master advanced authentication patterns, secure API routes against injection attacks, implement Zero Trust architecture, and deploy production-ready applications that can withstand modern threats. Whether you're building with the new App Router, Server Actions, or traditional Pages Router, you'll discover security strategies that protect your users and your business.
What makes this guide different? Unlike basic security checklists, we dive deep into real-world scenarios with production-ready code examples, advanced security patterns, and expert techniques used by top tech companies. You'll learn to secure server/client boundaries, implement comprehensive input validation with Zod, configure Content Security Policies that actually work, and monitor security events in real-time.
Why Next.js security matters more than ever
The stakes for web application security have never been higher. According to recent studies, the average cost of a data breach in 2025 exceeds $4.5 million, with web applications being the primary attack vector in over 43% of breaches. For Next.js developers, understanding security isn't optional – it's essential for protecting your users, maintaining trust, and avoiding catastrophic financial losses.
Next.js, built on React and Node.js, inherits security considerations from both client-side and server-side environments. This dual nature creates unique security challenges that require specialized knowledge and careful implementation. The good news? Next.js provides powerful built-in security features and supports industry-standard security practices when properly configured.
Understanding the Next.js security landscape
The unique security challenges of Next.js applications
Next.js applications face distinct security challenges due to their hybrid nature. Unlike traditional single-page applications (SPAs) or server-rendered applications, Next.js combines:
- Server-Side Rendering (SSR): Code execution on the server before sending HTML to clients
- Static Site Generation (SSG): Pre-built pages that can expose build-time data
- API Routes: Backend functionality within the same codebase
- Client-Side Navigation: Dynamic routing that happens in the browser
- Edge Runtime: Code running at the edge with different security contexts
This architectural complexity means developers must consider security implications across multiple execution contexts, making Next.js security a multifaceted challenge requiring comprehensive understanding.
Common Next.js security vulnerabilities
Before diving into solutions, let's examine the most prevalent security vulnerabilities in Next.js applications:
1. Cross-site scripting (XSS) attacks
XSS remains one of the most dangerous vulnerabilities in web applications. In Next.js, XSS can occur through:
- Improper use of
dangerouslySetInnerHTML
- Unvalidated user input in dynamic content
- Third-party scripts and dependencies
- Server-side rendering of malicious content
2. Cross-site request forgery (CSRF)
CSRF attacks trick authenticated users into performing unwanted actions. Next.js doesn't include built-in CSRF protection, making applications vulnerable without proper implementation.
3. Authentication and authorization flaws
Common authentication vulnerabilities in Next.js include:
- Insecure session management
- Weak token validation
- Missing authorization checks on API routes
- Client-side only authentication
4. API route security issues
Next.js API routes can be vulnerable to:
- Injection attacks (SQL, NoSQL, command injection)
- Rate limiting bypass
- Information disclosure through error messages
- Missing input validation
5. Dependency vulnerabilities
The JavaScript ecosystem's reliance on numerous packages creates supply chain risks through:
- Outdated dependencies with known vulnerabilities
- Malicious packages
- Transitive dependency issues
Next.js App Router security: securing modern applications
What is the App Router? If you're new to Next.js 13+, the App Router is the modern way to build Next.js applications. It introduces new concepts like Server Components and Server Actions that fundamentally change how we handle security.
Why does this matter for security? The App Router lets us run code on the server in new ways, which creates both opportunities and challenges for keeping our applications secure.
Securing Server Actions (step-by-step guide)
What are Server Actions? Server Actions are functions that run on the server but can be called directly from your React components. Think of them as a secure way to handle form submissions and data updates.
Let's start with a simple example before diving into the complex one:
Define your validation schema
// app/actions/simple-example.ts
"use server"; // This tells Next.js this code runs on the server
import { z } from "zod";
// Define what data we expect (validation schema)
const messageSchema = z.object({
message: z
.string()
.min(1, "Message cannot be empty")
.max(100, "Message too long"),
});
Create your secure server action
// Create our server action
export async function saveMessage(formData: FormData) {
// Get the message from the form
const rawMessage = formData.get("message");
// Validate the input - this prevents bad data
const result = messageSchema.safeParse({ message: rawMessage });
if (!result.success) {
throw new Error("Invalid message");
}
// Now we can safely save the validated message
console.log("Saving message:", result.data.message);
return { success: true };
}
Now let's look at a more complete example with full security measures:
// app/actions/user.ts
"use server";
import { z } from "zod";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { ratelimit } from "@/lib/ratelimit";
// Step 1: Define what data we expect from the form
const updateProfileSchema = z.object({
name: z
.string()
.min(2, "Name must be at least 2 characters")
.max(50, "Name is too long")
.transform((val) => val.trim()), // Remove extra spaces
email: z.string().email("Please enter a valid email"),
bio: z.string().max(500, "Bio is too long").optional(),
});
export async function updateProfile(formData: FormData) {
// Step 2: Check if user is logged in
const session = await auth();
if (!session?.user?.id) {
throw new Error("You must be logged in to update your profile");
}
// Step 3: Prevent spam by limiting how often users can update
const { success } = await ratelimit.limit(
`update-profile:${session.user.id}`,
);
if (!success) {
throw new Error("Please wait before updating your profile again");
}
// Step 4: Get the data from the form and validate it
const rawData = {
name: formData.get("name"),
email: formData.get("email"),
bio: formData.get("bio"),
};
// Parse and validate the data - this stops malicious input
const validatedData = updateProfileSchema.parse(rawData);
// Step 5: Check if user is allowed to make this change
if (session.user.email !== validatedData.email) {
// If they're changing email, they need extra verification
throw new Error("Email changes require verification");
}
try {
// Step 6: Save to database safely
await db.transaction(async (tx) => {
await tx.user.update({
where: { id: session.user.id },
data: validatedData,
});
});
// Step 7: Update the page to show new data
revalidatePath("/profile");
return { success: true };
} catch (error) {
// Step 8: Log error for debugging but don't expose details to user
console.error("Profile update failed:", error);
throw new Error("Something went wrong. Please try again.");
}
}
Always validate input
Never trust data from forms. Use Zod schemas to validate every piece of user input before processing.
Check authentication
Make sure the user is logged in before allowing any sensitive operations. Verify tokens server-side.
Rate limiting
Prevent spam and abuse by limiting how often users can perform actions. Essential for auth endpoints.
Authorization
Check if the user is allowed to perform this specific action. Authentication ≠ Authorization.
Safe database operations
Use transactions to prevent data corruption and ensure atomicity of complex operations.
Error handling
Don't leak sensitive information in error messages. Log details server-side, show generic messages to users.
Server component security patterns
What are Server Components? Server Components run on your server before sending HTML to the browser. This means they can safely access your database and other sensitive resources without exposing them to users.
Here's a simple example first:
// app/dashboard/simple-page.tsx
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'
export default async function SimpleDashboard() {
// Check if user is logged in (this happens on the server)
const session = await auth()
// If not logged in, send them to login page
if (!session?.user) {
redirect('/login')
}
// Now we know the user is authenticated
return (
<div>
<h1>Welcome to your dashboard, {session.user.name}!</h1>
<p>Your email: {session.user.email}</p>
</div>
)
}
Now a more advanced example with data fetching:
// app/dashboard/page.tsx
import { auth } from '@/lib/auth'
import { getUserData, getUserPermissions } from '@/lib/data'
import { redirect } from 'next/navigation'
export default async function DashboardPage() {
// Step 1: Check if user is logged in (this happens on the server)
const session = await auth()
if (!session?.user?.id) {
redirect('/login') // Send them to login if not authenticated
}
// Step 2: Get user data and permissions safely
// Both of these functions run on the server, so they're secure
const [userData, permissions] = await Promise.all([
getUserData(session.user.id),
getUserPermissions(session.user.id)
])
// Step 3: Render the page with the secure data
return (
<div>
<h1>Welcome, {userData.name}</h1>
{/* Only show analytics if user has permission */}
{permissions.canViewAnalytics && (
<AnalyticsSection userId={session.user.id} />
)}
</div>
)
}
// This function safely gets user data from the database
async function getUserData(userId: string) {
const user = await db.user.findUnique({
where: { id: userId },
select: {
id: true,
name: true,
email: true,
// IMPORTANT: Never select passwords or other sensitive data
}
})
if (!user) {
throw new Error('User not found')
}
return user
}
Why this is secure:
- Authentication happens on the server where users can't tamper with it
- We fetch only the data the user is allowed to see
- Sensitive data like passwords never leave the database
Secure client/server boundaries
What does this mean? In Next.js, some code runs on the server (secure) and some runs in the user's browser (not secure). Understanding this boundary is crucial for security.
The Golden Rule
Never send sensitive data to client components! Anything passed to a client component can be viewed by users in their browser developer tools.
Here's how to do it correctly:
// app/components/UserProfile.tsx (Server Component - secure)
import { auth } from '@/lib/auth'
import { ClientUserProfile } from './ClientUserProfile'
export default async function UserProfile() {
// This runs on the server - secure!
const session = await auth()
if (!session?.user) {
return <div>Please log in</div>
}
// Get sensitive data (only available on server)
const sensitiveData = await getUserSensitiveData(session.user.id)
// IMPORTANT: Only pass safe data to client components
const safeUserData = {
id: session.user.id,
name: session.user.name,
email: session.user.email,
// DON'T include: passwords, API keys, private info
}
return (
<div>
{/* Server-rendered content can show sensitive data */}
<h2>Account Information</h2>
<p>Account type: {sensitiveData.accountType}</p>
<p>Subscription expires: {sensitiveData.subscriptionEnd}</p>
{/* Client component only gets safe data */}
<ClientUserProfile user={safeUserData} />
</div>
)
}
// app/components/ClientUserProfile.tsx (Client Component)
'use client' // This runs in the user's browser
import { useState } from 'react'
// Define exactly what data this component expects
interface SafeUser {
id: string
name: string
email: string
// Notice: no sensitive fields here!
}
export function ClientUserProfile({ user }: { user: SafeUser }) {
const [isEditing, setIsEditing] = useState(false)
// This is safe because we only received pre-approved data
return (
<div>
<p>Hello, {user.name}!</p>
<button onClick={() => setIsEditing(!isEditing)}>
{isEditing ? 'Cancel' : 'Edit Profile'}
</button>
{isEditing && <UserEditForm user={user} />}
</div>
)
}
Key points for beginners:
- Server Components = secure, can access database
- Client Components = run in browser, never trust them with sensitive data
- Always filter data before passing it to client components
App Router middleware security
What is middleware? Middleware is code that runs before your pages load. It's perfect for security checks like authentication and rate limiting.
Here's a beginner-friendly middleware example:
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
export async function middleware(request: NextRequest) {
const url = request.nextUrl.pathname;
// Step 1: Check if this page needs authentication
const protectedPages = ["/dashboard", "/profile", "/admin"];
const needsAuth = protectedPages.some((page) => url.startsWith(page));
if (needsAuth) {
// Step 2: Check if user is logged in
const session = await auth();
if (!session?.user) {
// Step 3: Redirect to login if not authenticated
return NextResponse.redirect(new URL("/login", request.url));
}
// Step 4: Check if user has required permissions
if (url.startsWith("/admin") && session.user.role !== "admin") {
return NextResponse.redirect(new URL("/dashboard", request.url));
}
}
// Step 5: Add security headers to all responses
const response = NextResponse.next();
// These headers protect against common attacks
response.headers.set("X-Content-Type-Options", "nosniff");
response.headers.set("X-Frame-Options", "DENY");
response.headers.set("X-XSS-Protection", "1; mode=block");
return response;
}
// Tell Next.js which pages to run middleware on
export const config = {
matcher: [
// Run on all pages except static files
"/((?!_next/static|_next/image|favicon.ico|public/).*)",
],
};
What each security header does:
X-Content-Type-Options: nosniff
- Prevents browsers from guessing file typesX-Frame-Options: DENY
- Prevents your site from being embedded in iframesX-XSS-Protection: 1; mode=block
- Enables browser XSS protection
Route groups and layout security
What are route groups? Route groups let you organize your pages and apply security to entire sections of your app.
// app/(dashboard)/layout.tsx
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
// Step 1: Check authentication at the layout level
// This protects ALL pages in the (dashboard) group
const session = await auth()
if (!session?.user) {
redirect('/login')
}
// Step 2: Check permissions for dashboard access
if (!session.user.permissions?.includes('dashboard_access')) {
redirect('/unauthorized')
}
// Step 3: Render the layout with navigation
return (
<div className="dashboard-layout">
<nav>
<h2>Dashboard</h2>
{/* Show admin link only if user is admin */}
{session.user.permissions?.includes('admin') && (
<a href="/admin">Admin Panel</a>
)}
</nav>
<main>{children}</main>
</div>
)
}
Why this is powerful:
- One layout protects multiple pages
- Authentication happens once for the entire section
- Navigation can show/hide based on permissions
Implementing robust authentication in Next.js
Authentication is about proving who you are. Let's explore the most common and secure ways to handle this in Next.js.
NextAuth.js: the gold standard for Next.js authentication
Why use NextAuth.js? It handles the complex security details for you and supports many login methods (Google, GitHub, email/password, etc.).
Here's a simple setup:
// app/api/auth/[...nextauth]/route.ts
import NextAuth from "next-auth";
import GoogleProvider from "next-auth/providers/google";
import CredentialsProvider from "next-auth/providers/credentials";
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import prisma from "~/lib/prisma";
const handler = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
CredentialsProvider({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
// Implement secure password verification
const user = await verifyUser(credentials);
if (user) {
return user;
}
return null;
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.role = user.role;
}
return token;
},
async session({ session, token }) {
session.user.role = token.role;
return session;
},
},
session: {
strategy: "jwt",
maxAge: 30 * 24 * 60 * 60, // 30 days
},
pages: {
signIn: "/auth/signin",
error: "/auth/error",
},
});
export { handler as GET, handler as POST };
What this setup gives you:
- Secure login with Google or email/password
- Automatic session management
- Built-in security features (CSRF protection, etc.)
- Easy access to user info in your components
JWT implementation in Next.js: best practices
What is JWT? JWT (JSON Web Token) is like a secure ID card for your users. It contains their information in an encrypted format.
When to use JWT vs NextAuth.js:
- NextAuth.js = easier, handles everything for you
- Custom JWT = more control, but more work
Here's how to implement JWT securely if you need custom authentication:
// lib/jwt.js
import jwt from "jsonwebtoken";
import { serialize } from "cookie";
// Get your secret key from environment variables
const JWT_SECRET = process.env.JWT_SECRET;
const JWT_EXPIRES_IN = "7d"; // Token expires in 7 days
// Create a token when user logs in
export function generateAccessToken(userInfo) {
return jwt.sign(userInfo, JWT_SECRET, {
expiresIn: JWT_EXPIRES_IN,
algorithm: "HS256", // Secure algorithm
});
}
// Create a long-term refresh token
export function generateRefreshToken(userInfo) {
return jwt.sign(userInfo, process.env.REFRESH_TOKEN_SECRET, {
expiresIn: "30d", // Refresh token lasts longer
algorithm: "HS256",
});
}
// Check if a token is valid
export function verifyToken(token) {
try {
return jwt.verify(token, JWT_SECRET);
} catch (error) {
throw new Error("Invalid or expired token");
}
}
// Store token in a secure cookie
export function setTokenCookie(res, token) {
const cookie = serialize("auth-token", token, {
httpOnly: true, // Prevents JavaScript from accessing the cookie
secure: process.env.NODE_ENV === "production", // HTTPS only in production
sameSite: "strict", // Prevents CSRF attacks
maxAge: 60 * 60 * 24 * 7, // 7 days
path: "/", // Available on all pages
});
res.setHeader("Set-Cookie", cookie);
}
Security best practices for JWT:
- Use strong secrets - JWT_SECRET should be at least 32 characters
- Set expiration times - Don't make tokens last forever
- Store in httpOnly cookies - Safer than localStorage
- Use refresh tokens - For better security and user experience
OAuth implementation strategies
What is OAuth? OAuth lets users log in with accounts they already have (like Google, GitHub, Facebook) instead of creating new passwords.
Why use OAuth?
- Users don't need to remember another password
- You don't store passwords (less security risk)
- Users trust familiar login screens
Here's a simple OAuth flow:
// app/api/auth/oauth/callback/route.ts
import { NextRequest, NextResponse } from "next/server";
import { OAuth2Client } from "google-auth-library";
import { generateAccessToken, setTokenCookie } from "~/lib/jwt";
// Set up Google OAuth client
const client = new OAuth2Client(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
process.env.GOOGLE_REDIRECT_URI, // Where Google sends users back
);
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const code = searchParams.get("code"); // Google sends this code
try {
// Step 1: Exchange the code for user information
const { tokens } = await client.getToken(code);
client.setCredentials(tokens);
// Step 2: Verify the user's identity
const ticket = await client.verifyIdToken({
idToken: tokens.id_token,
audience: process.env.GOOGLE_CLIENT_ID,
});
const userInfo = ticket.getPayload();
// Step 3: Create or update user in your database
const user = await createOrUpdateUser({
email: userInfo.email,
name: userInfo.name,
picture: userInfo.picture,
googleId: userInfo.sub, // Google's ID for this user
});
// Step 4: Create your own token for the user
const appToken = generateAccessToken({
userId: user.id,
email: user.email,
role: user.role,
});
// Step 5: Store token in secure cookie
const response = NextResponse.redirect(new URL("/dashboard", req.url));
setTokenCookie(response, appToken);
// Step 6: Send user to their dashboard
return response;
} catch (error) {
console.error("OAuth error:", error);
return NextResponse.redirect(new URL("/auth/error?error=oauth_failed", req.url));
}
}
The OAuth flow explained:
- User clicks "Login with Google"
- Google verifies their identity
- Google sends them back to your app with a code
- You exchange the code for user information
- You create a user account (if needed) and log them in
Securing Next.js API routes
What are API routes? API routes are like the "back door" to your application. They handle requests from your frontend, process data, and talk to your database.
Why secure them? If someone can access your API routes directly, they might steal data or break your application.
Authentication middleware for API routes
What is middleware? Think of middleware as a security guard that checks everyone before they enter a building.
Here's a simple authentication middleware:
// middleware/auth.js
import { verifyToken } from "../lib/jwt";
export function withAuth(handler) {
return async (req, res) => {
try {
// Step 1: Look for the authentication token
const token =
req.cookies["auth-token"] || // Check cookies first
req.headers.authorization?.replace("Bearer ", ""); // Then check headers
if (!token) {
return res.status(401).json({ error: "Please log in first" });
}
// Step 2: Verify the token is valid
const userInfo = verifyToken(token);
// Step 3: Add user info to the request so the handler can use it
req.user = userInfo;
// Step 4: Continue to the actual API function
return handler(req, res);
} catch (error) {
return res.status(401).json({ error: "Invalid or expired token" });
}
};
}
// How to use it in an API route
// app/api/protected/data/route.ts
import { NextRequest, NextResponse } from "next/server";
import { withAuth } from "~/middleware/auth";
async function handler(req: NextRequest) {
// req.user is available here because of the middleware
const userId = req.user.userId;
// Get data that belongs to this user
const userData = await fetchUserData(userId);
return NextResponse.json({ data: userData });
}
// Wrap your handler with authentication
export const GET = withAuth(handler);
How this protects your API:
- Only logged-in users can access the endpoint
- You know exactly who is making the request
- Invalid tokens are rejected automatically
Rate limiting implementation
What is rate limiting? It's like a speed limit for your API. It prevents users from making too many requests too quickly.
Why do you need it?
- Prevents spam and abuse
- Protects your server from being overwhelmed
- Stops malicious users from attacking your API
Here's how to implement it:
// middleware/rateLimit.js
import rateLimit from "express-rate-limit";
// Create different rate limits for different types of endpoints
// General API rate limit - not too strict
export const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Allow 100 requests per 15 minutes per IP
message: "Too many requests. Please try again in 15 minutes.",
standardHeaders: true, // Send rate limit info in headers
legacyHeaders: false,
});
// Stricter limit for authentication endpoints
export const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // Only 5 login attempts per 15 minutes
skipSuccessfulRequests: true, // Don't count successful logins
message: "Too many login attempts. Please try again in 15 minutes.",
});
// Helper function to use rate limiting in Next.js
export function runMiddleware(req, res, fn) {
return new Promise((resolve, reject) => {
fn(req, res, (result) => {
if (result instanceof Error) {
return reject(result);
}
return resolve(result);
});
});
}
// How to use it in an API route
// app/api/login/route.ts
import { NextRequest, NextResponse } from "next/server";
import { runMiddleware, authLimiter } from "~/middleware/rateLimit";
export async function POST(req: NextRequest) {
// Apply rate limiting before processing the request
await runMiddleware(req, authLimiter);
// Now handle the login request
// If user gets here, they haven't exceeded the rate limit
const { email, password } = await req.json();
// Your login logic here...
return NextResponse.json({ success: true });
}
Rate limiting best practices:
- Stricter limits for sensitive endpoints (login, password reset)
- More lenient limits for general API endpoints
- Always inform users why their request was blocked
- Consider different limits for authenticated vs anonymous users
Input validation and sanitization with Zod
What is input validation? It's like checking IDs at a club entrance - you verify that the data coming into your API is safe and valid.
Why is this crucial? Malicious users might send harmful data to try to break your application or steal information.
What is Zod? Zod is a TypeScript library that makes input validation easy and type-safe. It helps you catch bad data before it causes problems.
Let's start with a simple example:
// lib/validation.ts
import { z } from "zod";
import DOMPurify from "isomorphic-dompurify";
// Simple validation example - a contact form
const contactFormSchema = z.object({
name: z
.string()
.min(2, "Name must be at least 2 characters")
.max(50, "Name is too long"),
email: z.string().email("Please enter a valid email"),
message: z
.string()
.min(10, "Message must be at least 10 characters")
.max(1000, "Message is too long"),
});
// How to use it in an API route
// app/api/contact/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
try {
// Validate the incoming data
const body = await req.json();
const validData = contactFormSchema.parse(body);
// Now we know the data is safe to use
await saveContactMessage(validData);
return NextResponse.json({ message: "Thank you for your message!" });
} catch (error) {
// If validation fails, tell the user what's wrong
return NextResponse.json(
{
error: "Invalid data",
details: error.errors,
},
{ status: 400 },
);
}
}
Now let's look at more advanced validation patterns:
// Advanced user registration validation
export const userRegistrationSchema = z.object({
email: z
.string()
.email("Invalid email format")
.toLowerCase() // Convert to lowercase automatically
.transform((val) => val.trim()), // Remove extra spaces
password: z
.string()
.min(8, "Password must be at least 8 characters")
.max(100, "Password too long")
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
"Password must contain uppercase, lowercase, number, and special character",
),
name: z
.string()
.min(2, "Name must be at least 2 characters")
.max(50, "Name too long")
.transform((val) => DOMPurify.sanitize(val.trim())), // Clean and trim
age: z
.number()
.int("Age must be a whole number")
.min(13, "Must be at least 13 years old")
.max(150, "Invalid age"),
phoneNumber: z
.string()
.regex(/^\+?[\d\s\-\(\)]{10,}$/, "Invalid phone number format")
.optional() // This field is not required
.transform((val) => (val ? val.replace(/\D/g, "") : undefined)), // Remove non-digits
termsAccepted: z
.boolean()
.refine((val) => val === true, "You must accept the terms and conditions"),
});
// File upload validation
export const fileUploadSchema = z.object({
file: z
.instanceof(File)
.refine(
(file) => file.size <= 5 * 1024 * 1024,
"File must be less than 5MB",
)
.refine(
(file) => ["image/jpeg", "image/png", "image/webp"].includes(file.type),
"Only JPEG, PNG, and WebP images are allowed",
),
description: z
.string()
.max(500, "Description too long")
.transform((val) => DOMPurify.sanitize(val))
.optional(),
});
// Validation that depends on user role
export const createPostSchemaForRole = (userRole: string) => {
const baseSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1).max(10000),
tags: z.array(z.string()).max(10).default([]),
});
// Admins get extra features
if (userRole === "admin") {
return baseSchema.extend({
featuredPost: z.boolean().default(false),
publishedAt: z.string().datetime().optional(),
authorId: z.string().uuid().optional(), // Admins can post as other users
});
}
// Premium users get some extra features
if (userRole === "premium") {
return baseSchema.extend({
publishedAt: z.string().datetime().optional(),
customSlug: z
.string()
.regex(/^[a-z0-9-]+$/)
.optional(),
});
}
return baseSchema; // Regular users get basic schema
};
Key validation principles:
- Validate everything - Never trust user input
- Fail fast - Check data before processing it
- Clear error messages - Help users fix their mistakes
- Transform data - Clean and normalize input automatically
- Role-based validation - Different rules for different user types
CSRF protection in Next.js
What is CSRF? Cross-Site Request Forgery is when a malicious website tricks your browser into making requests to another website where you're logged in.
Real-world example: You're logged into your bank website. You visit a malicious site that secretly sends a request to transfer money from your bank account.
How CSRF protection works: We use special tokens that prove the request really came from your application, not a malicious site.
Here's how to implement CSRF protection:
// lib/csrf.js
import { randomBytes } from "crypto";
import { serialize } from "cookie";
const CSRF_TOKEN_LENGTH = 32;
const CSRF_COOKIE_NAME = "csrf-token";
const CSRF_HEADER_NAME = "x-csrf-token";
// Generate a random token
export function generateCSRFToken() {
return randomBytes(CSRF_TOKEN_LENGTH).toString("hex");
}
// Store the token in a secure cookie
export function setCSRFCookie(res, token) {
const cookie = serialize(CSRF_COOKIE_NAME, token, {
httpOnly: true, // JavaScript can't access this cookie
secure: process.env.NODE_ENV === "production", // HTTPS only in production
sameSite: "strict", // Only send to your domain
maxAge: 60 * 60 * 24, // 24 hours
path: "/",
});
res.setHeader("Set-Cookie", cookie);
}
// Check if the CSRF token is valid
export function validateCSRFToken(req) {
const cookieToken = req.cookies[CSRF_COOKIE_NAME];
const headerToken = req.headers[CSRF_HEADER_NAME];
if (!cookieToken || !headerToken) {
return false;
}
// Both tokens must match
return cookieToken === headerToken;
}
// Middleware to protect your API routes
export function withCSRF(handler) {
return async (req, res) => {
// GET requests are usually safe, so we skip CSRF for them
if (req.method === "GET") {
return handler(req, res);
}
// Check CSRF token for all other requests
if (!validateCSRFToken(req)) {
return res.status(403).json({ error: "Invalid CSRF token" });
}
return handler(req, res);
};
}
How to use CSRF protection in your frontend:
// lib/api-client.js
export async function apiCall(url, options = {}) {
// Get the CSRF token from the cookie
const csrfToken = getCookie("csrf-token");
const response = await fetch(url, {
...options,
headers: {
...options.headers,
"x-csrf-token": csrfToken, // Include token in request
"Content-Type": "application/json",
},
credentials: "include", // Include cookies
});
if (!response.ok) {
throw new Error(`API call failed: ${response.statusText}`);
}
return response.json();
}
// Helper function to get cookie value
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(";").shift();
}
CSRF protection checklist:
- Generate unique tokens for each session
- Include tokens in all non-GET requests
- Validate tokens on the server
- Use SameSite cookies for extra protection
XSS prevention strategies
What is XSS? Cross-Site Scripting is when malicious JavaScript code gets injected into your website and runs in other users' browsers.
Real-world example: A user posts a comment with malicious JavaScript. When other users view the comment, the malicious code runs and could steal their login cookies.
The good news: React and Next.js have built-in protection against most XSS attacks, but you still need to be careful.
Content security policy implementation
What is Content Security Policy (CSP)? Think of it as a bouncer for your website - it controls what resources (scripts, styles, images) are allowed to load.
Why is CSP powerful? Even if malicious code somehow gets into your site, CSP can prevent it from running or stealing data.
Let's start with a simple CSP setup:
// lib/csp.ts
import { randomBytes } from "crypto";
export interface CSPConfig {
nonce?: string;
isDevelopment?: boolean;
allowedDomains?: string[];
reportUri?: string;
}
export function generateNonce(): string {
return randomBytes(16).toString("base64");
}
export function buildCSP(config: CSPConfig = {}): string {
const {
nonce,
isDevelopment = process.env.NODE_ENV === "development",
allowedDomains = [],
reportUri,
} = config;
// Strict CSP for production
const cspDirectives = {
"default-src": ["'self'"],
"script-src": [
"'self'",
// Use nonce for inline scripts instead of 'unsafe-inline'
nonce ? `'nonce-${nonce}'` : "'unsafe-inline'",
// Allow specific trusted domains
"https://www.googletagmanager.com",
"https://www.google-analytics.com",
"https://cdn.vercel-insights.com",
...allowedDomains.map((domain) => `https://${domain}`),
],
"style-src": [
"'self'",
// Hash-based CSP for known inline styles (better than 'unsafe-inline')
"'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='", // Empty string hash
// Allow Google Fonts
"https://fonts.googleapis.com",
],
"img-src": [
"'self'",
"blob:",
"data:",
"https:", // Allow all HTTPS images (can be restricted further)
// Add specific domains for better security
"https://*.githubusercontent.com",
"https://images.unsplash.com",
],
"font-src": ["'self'", "https://fonts.gstatic.com"],
"connect-src": [
"'self'",
"https://vitals.vercel-insights.com",
"https://api.stripe.com",
"https://analytics.google.com",
// Add your API domains
...(process.env.NEXT_PUBLIC_API_URL
? [process.env.NEXT_PUBLIC_API_URL]
: []),
],
"frame-src": [
"'none'", // Prevent all framing by default
],
"object-src": ["'none'"],
"base-uri": ["'self'"],
"form-action": ["'self'"],
"frame-ancestors": ["'none'"],
"media-src": ["'self'"],
"manifest-src": ["'self'"],
"worker-src": ["'self'", "blob:"],
// Modern CSP directives
"trusted-types": isDevelopment ? [] : ["'none'"],
"require-trusted-types-for": isDevelopment ? [] : ["'script'"],
};
// Add report-uri for CSP violation reporting
if (reportUri) {
cspDirectives["report-uri"] = [reportUri];
cspDirectives["report-to"] = ["csp-endpoint"];
}
// Convert to CSP string
const cspString = Object.entries(cspDirectives)
.filter(([_, values]) => values.length > 0)
.map(([directive, values]) => `${directive} ${values.join(" ")}`)
.join("; ");
return cspString;
}
// Nonce-based script execution
export function createScriptWithNonce(nonce: string, content: string): string {
return `<script nonce="${nonce}">${content}</script>`;
}
// CSP violation reporting endpoint
export interface CSPViolationReport {
"document-uri": string;
referrer: string;
"violated-directive": string;
"effective-directive": string;
"original-policy": string;
disposition: string;
"blocked-uri": string;
"status-code": number;
"script-sample": string;
}
// middleware.ts - Enhanced CSP implementation
import { NextRequest, NextResponse } from "next/server";
import { buildCSP, generateNonce } from "@/lib/csp";
export async function middleware(request: NextRequest) {
const response = NextResponse.next();
// Generate unique nonce for each request
const nonce = generateNonce();
// Build CSP with nonce
const csp = buildCSP({
nonce,
isDevelopment: process.env.NODE_ENV === "development",
allowedDomains: ["api.example.com"], // Add your trusted domains
reportUri: "/api/csp-report",
});
// Set CSP header
response.headers.set("Content-Security-Policy", csp);
// Add nonce to request headers for use in components
response.headers.set("x-nonce", nonce);
// Additional security headers
const securityHeaders = {
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"X-XSS-Protection": "1; mode=block",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Permissions-Policy":
"camera=(), microphone=(), geolocation=(), payment=()",
"Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload",
"Cross-Origin-Embedder-Policy": "require-corp",
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Resource-Policy": "same-origin",
};
Object.entries(securityHeaders).forEach(([key, value]) => {
response.headers.set(key, value);
});
return response;
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|public/).*)"],
};
// components/CSPProvider.tsx - React context for CSP nonce
'use client'
import { createContext, useContext, ReactNode } from 'react'
import { headers } from 'next/headers'
interface CSPContextType {
nonce: string | null
}
const CSPContext = createContext<CSPContextType>({ nonce: null })
export function CSPProvider({ children }: { children: ReactNode }) {
// Get nonce from headers (set by middleware)
const nonce = headers().get('x-nonce')
return (
<CSPContext.Provider value={{ nonce }}>
{children}
</CSPContext.Provider>
)
}
export function useCSPNonce() {
const context = useContext(CSPContext)
if (!context) {
throw new Error('useCSPNonce must be used within CSPProvider')
}
return context.nonce
}
// Safe script component that uses nonce
export function SafeScript({
children,
src,
async = false,
defer = false
}: {
children?: string
src?: string
async?: boolean
defer?: boolean
}) {
const nonce = useCSPNonce()
if (src) {
return (
<script
src={src}
nonce={nonce || undefined}
async={async}
defer={defer}
/>
)
}
return (
<script
nonce={nonce || undefined}
dangerouslySetInnerHTML={{ __html: children || '' }}
/>
)
}
// app/api/csp-report/route.ts - CSP violation reporting
import { NextRequest, NextResponse } from "next/server";
import { CSPViolationReport } from "~/lib/csp";
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const report = body as { "csp-report": CSPViolationReport };
const violation = report["csp-report"];
// Log violation (in production, send to monitoring service)
console.warn("CSP Violation:", {
uri: violation["document-uri"],
directive: violation["violated-directive"],
blockedUri: violation["blocked-uri"],
sample: violation["script-sample"],
userAgent: req.headers.get("user-agent"),
timestamp: new Date().toISOString(),
});
// Store in database or send to monitoring service
await storeCSPViolation({
...violation,
userAgent: req.headers.get("user-agent") || "",
ip: req.headers.get("x-forwarded-for") || req.ip || "",
timestamp: new Date(),
});
return new NextResponse(null, { status: 204 });
} catch (error) {
console.error("Error processing CSP report:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
async function storeCSPViolation(violation: any) {
// Implement your storage logic here
// Examples: Database insert, send to logging service, etc.
}
// app/layout.tsx - Using CSP in App Router
import { headers } from 'next/headers'
import { CSPProvider } from '@/components/CSPProvider'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<head>
{/* Meta CSP for legacy support */}
<meta
httpEquiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-inline'"
/>
</head>
<body>
<CSPProvider>
{children}
</CSPProvider>
</body>
</html>
)
}
// lib/csp-configs.ts - Development configuration
export const developmentCSP = {
"script-src": [
"'self'",
"'unsafe-eval'", // Required for hot reloading
"'unsafe-inline'",
"http://localhost:*",
],
"connect-src": [
"'self'",
"ws://localhost:*", // WebSocket for hot reloading
"http://localhost:*",
],
"style-src": [
"'self'",
"'unsafe-inline'", // Allow inline styles for development
],
};
// lib/csp-configs.ts - Production configuration
export const productionCSP = {
"script-src": [
"'self'",
// No unsafe directives in production
"https://www.googletagmanager.com",
"https://cdn.vercel-insights.com",
],
"connect-src": [
"'self'",
"https:", // Only HTTPS connections
],
"style-src": [
"'self'",
"https://fonts.googleapis.com",
// Use specific hashes instead of 'unsafe-inline'
],
};
// lib/csp-configs.ts - Staging configuration
export const stagingCSP = {
"script-src": [
"'self'",
"'unsafe-inline'", // May be needed for testing
],
"connect-src": [
"'self'",
"https:",
],
"report-uri": ["/api/csp-report"], // Enable violation reporting
"report-to": ["csp-endpoint"],
};
// Progressive CSP implementation
export function getCSPForEnvironment(env: string) {
const configs = {
development: developmentCSP,
production: productionCSP,
staging: stagingCSP,
};
return configs[env as keyof typeof configs] || configs.production;
}
CSP testing and monitoring
// lib/csp-testing.ts
export class CSPTester {
private violations: CSPViolationReport[] = [];
// Test CSP policies before deploying
async testCSPPolicy(policy: string, testUrls: string[]): Promise<boolean> {
for (const url of testUrls) {
const violations = await this.simulateCSP(policy, url);
if (violations.length > 0) {
console.error(`CSP violations found for ${url}:`, violations);
return false;
}
}
return true;
}
private async simulateCSP(policy: string, url: string) {
// Implementation would use headless browser to test CSP
// This is a simplified example
return [];
}
// Monitor CSP violations in production
analyzeViolations(violations: CSPViolationReport[]) {
const grouped = violations.reduce(
(acc, violation) => {
const key = violation["violated-directive"];
acc[key] = (acc[key] || 0) + 1;
return acc;
},
{} as Record<string, number>,
);
return {
totalViolations: violations.length,
byDirective: grouped,
mostCommon: Object.entries(grouped)
.sort(([, a], [, b]) => b - a)
.slice(0, 5),
};
}
}
Safe HTML rendering
Why this matters: Sometimes you need to display HTML content from users (like blog posts or comments). This is dangerous if not done carefully.
React's built-in protection: React automatically escapes dangerous content, but you need to be careful with certain patterns.
Here's how to safely render HTML content:
// components/SafeHTML.js
import DOMPurify from "isomorphic-dompurify";
export function SafeHTML({ html, options = {} }) {
const defaultOptions = {
ALLOWED_TAGS: ["p", "br", "strong", "em", "u", "a", "ul", "ol", "li"],
ALLOWED_ATTR: ["href", "target", "rel"],
ALLOW_DATA_ATTR: false,
};
const cleanHTML = DOMPurify.sanitize(html, { ...defaultOptions, ...options });
return (
<div dangerouslySetInnerHTML={{ __html: cleanHTML }} className="prose" />
);
}
// Usage
<SafeHTML html={userGeneratedContent} />;
Next.js middleware for security
What is middleware? Middleware is code that runs before your pages load. It's perfect for security checks that need to happen on every request.
Why use middleware for security? It runs at the "edge" (close to your users) and can block bad requests before they even reach your application code.
Here's how to set up security middleware:
// middleware.js
import { NextResponse } from "next/server";
import { verifyToken } from "./lib/jwt";
// Define protected routes
const protectedRoutes = ["/dashboard", "/admin", "/api/protected"];
const publicRoutes = ["/login", "/register", "/"];
export async function middleware(request) {
const { pathname } = request.nextUrl;
// Check if route requires authentication
const isProtectedRoute = protectedRoutes.some((route) =>
pathname.startsWith(route),
);
if (isProtectedRoute) {
const token = request.cookies.get("auth-token");
if (!token) {
// Redirect to login
const url = request.nextUrl.clone();
url.pathname = "/login";
url.searchParams.set("from", pathname);
return NextResponse.redirect(url);
}
try {
// Verify token
const decoded = await verifyToken(token.value);
// Add user info to headers for API routes
if (pathname.startsWith("/api/")) {
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-user-id", decoded.userId);
requestHeaders.set("x-user-role", decoded.role);
return NextResponse.next({
request: {
headers: requestHeaders,
},
});
}
} catch (error) {
// Invalid token, redirect to login
const url = request.nextUrl.clone();
url.pathname = "/login";
return NextResponse.redirect(url);
}
}
// Apply security headers to all responses
const response = NextResponse.next();
// HSTS
response.headers.set(
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains",
);
return response;
}
export const config = {
matcher: [
/*
* Match all request paths except:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* - public folder
*/
"/((?!_next/static|_next/image|favicon.ico|public/).*)",
],
};
Environment variables security
What are environment variables? These are secret values (like database passwords, API keys) that your application needs but shouldn't be visible in your code.
Why is this important? If secrets leak into your code repository, hackers can steal them and access your systems.
The Next.js rule: Only variables starting with NEXT_PUBLIC_
are sent to the browser. Everything else stays on the server (safe).
Here's how to manage them securely:
// lib/env.js
// Validate environment variables at build time
const requiredEnvVars = [
"DATABASE_URL",
"JWT_SECRET",
"REFRESH_TOKEN_SECRET",
"ENCRYPTION_KEY",
];
// Server-only variables (never exposed to client)
const serverOnlyVars = [
"DATABASE_URL",
"JWT_SECRET",
"REFRESH_TOKEN_SECRET",
"ENCRYPTION_KEY",
"ADMIN_EMAIL",
"SMTP_PASSWORD",
];
// Validate required variables
export function validateEnv() {
const missing = requiredEnvVars.filter((varName) => !process.env[varName]);
if (missing.length > 0) {
throw new Error(
`Missing required environment variables: ${missing.join(", ")}`,
);
}
// Ensure server-only variables aren't prefixed with NEXT_PUBLIC_
serverOnlyVars.forEach((varName) => {
if (process.env[`NEXT_PUBLIC_${varName}`]) {
throw new Error(
`Security Error: ${varName} should not be prefixed with NEXT_PUBLIC_`,
);
}
});
}
// Safe environment variable access
export const env = {
// Server-only
database: {
url: process.env.DATABASE_URL,
},
auth: {
jwtSecret: process.env.JWT_SECRET,
refreshSecret: process.env.REFRESH_TOKEN_SECRET,
},
// Client-safe
public: {
apiUrl: process.env.NEXT_PUBLIC_API_URL,
appUrl: process.env.NEXT_PUBLIC_APP_URL,
},
};
// Call during build
validateEnv();
Database security with Next.js
Why database security matters: Your database contains all your valuable information. If someone gains unauthorized access, they could steal or delete everything.
Common database vulnerabilities:
- SQL injection attacks (malicious code in queries)
- Exposed connection strings
- Unencrypted sensitive data
- Too many database connections
Here's how to secure your database properly:
// lib/prisma.js
import { PrismaClient } from "@prisma/client";
import { fieldEncryptionMiddleware } from "prisma-field-encryption";
// Singleton pattern for Prisma client
let prisma;
if (process.env.NODE_ENV === "production") {
prisma = new PrismaClient({
log: ["error"],
errorFormat: "minimal",
});
// Add field encryption middleware
prisma.$use(
fieldEncryptionMiddleware({
encryptionKey: process.env.ENCRYPTION_KEY,
}),
);
} else {
if (!global.prisma) {
global.prisma = new PrismaClient({
log: ["query", "error", "warn"],
});
}
prisma = global.prisma;
}
// Connection pool configuration
export const dbConfig = {
connectionLimit: 10,
connectTimeout: 60000,
ssl: {
rejectUnauthorized: process.env.NODE_ENV === "production",
},
};
// Safe query builder
export async function safeQuery(model, operation, params) {
try {
// Validate model exists
if (!prisma[model]) {
throw new Error(`Invalid model: ${model}`);
}
// Execute query with timeout
const result = await Promise.race([
prisma[model][operation](params),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("Query timeout")), 30000),
),
]);
return result;
} catch (error) {
// Log error without exposing sensitive information
console.error(`Database error in ${model}.${operation}:`, {
message: error.message,
code: error.code,
});
// Return generic error to client
throw new Error("Database operation failed");
}
}
export default prisma;
Security testing and monitoring
Why test security? Even with all the security measures in place, you need to regularly check for vulnerabilities and monitor for attacks.
What to test:
- Authentication systems (can people break in?)
- Input validation (do forms reject malicious data?)
- API security (are endpoints properly protected?)
- Dependencies (do any libraries have known vulnerabilities?)
Here's how to implement security testing:
// scripts/security-audit.js
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
async function runSecurityAudit() {
console.log("🔍 Running security audit...\n");
// Check for dependency vulnerabilities
console.log("📦 Checking dependencies...");
try {
const { stdout: npmAudit } = await execAsync("npm audit --json");
const auditResults = JSON.parse(npmAudit);
if (auditResults.metadata.vulnerabilities.total > 0) {
console.warn(
`⚠️ Found ${auditResults.metadata.vulnerabilities.total} vulnerabilities`,
);
console.log('Run "npm audit fix" to fix automatically');
} else {
console.log("✅ No dependency vulnerabilities found");
}
} catch (error) {
console.error("❌ Dependency audit failed:", error.message);
}
// Check for exposed secrets
console.log("\n🔑 Checking for exposed secrets...");
try {
await execAsync('npx secretlint "**/*"');
console.log("✅ No exposed secrets found");
} catch (error) {
console.error("❌ Found exposed secrets!");
}
// Check security headers
console.log("\n🛡️ Checking security headers...");
const requiredHeaders = [
"Content-Security-Policy",
"X-Frame-Options",
"X-Content-Type-Options",
"Strict-Transport-Security",
];
// Add to package.json scripts
console.log("\n📋 Add to package.json:");
console.log('"audit": "node scripts/security-audit.js"');
}
runSecurityAudit();
Deployment security considerations
Vercel deployment security
// vercel.json
{
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "X-Content-Type-Options",
"value": "nosniff"
},
{
"key": "X-Frame-Options",
"value": "DENY"
},
{
"key": "X-XSS-Protection",
"value": "1; mode=block"
}
]
}
],
"functions": {
"pages/api/*": {
"maxDuration": 10
}
},
"env": {
"NODE_ENV": "production"
}
}
Docker security configuration
# Dockerfile
FROM node:18-alpine AS base
# Install security updates
RUN apk update && apk upgrade
# Create non-root user
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nextjs -u 1001
# Dependencies stage
FROM base AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
# Build stage
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build application
RUN npm run build
# Production stage
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
# Copy built application
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
# Use non-root user
USER nextjs
EXPOSE 3000
ENV PORT 3000
CMD ["node", "server.js"]
Role-based access control implementation
// lib/rbac.js
const permissions = {
admin: ["read", "write", "delete", "manage_users"],
editor: ["read", "write"],
viewer: ["read"],
guest: [],
};
export function hasPermission(userRole, action) {
return permissions[userRole]?.includes(action) || false;
}
// HOC for protected components
export function withRoleAuth(Component, requiredRole) {
return function ProtectedComponent(props) {
const { data: session } = useSession();
if (!session || !hasPermission(session.user.role, requiredRole)) {
return <AccessDenied />;
}
return <Component {...props} />;
};
}
// API route protection
export function requireRole(roles) {
return (handler) => async (req, res) => {
const userRole = req.user?.role;
if (!roles.includes(userRole)) {
return res.status(403).json({ error: "Insufficient permissions" });
}
return handler(req, res);
};
}
// Usage in App Router
export const GET = withAuth(requireRole(["admin", "editor"])(handler));
export const POST = withAuth(requireRole(["admin", "editor"])(handler));
The ultimate Next.js security audit checklist
Use this comprehensive security checklist to systematically audit your Next.js application. Each section provides actionable security measures organized by priority and implementation complexity.
How to use this checklist?
Review each section systematically. Start with Essential items for immediate security, then progress through Important and Advanced measures based on your application's needs and risk profile.
Authentication Security Audit
Security Area | Priority | Implementation | Validation |
---|---|---|---|
JWT Security | Essential | 32+ char secrets, secure storage, environment separation | echo $JWT_SECRET | wc -c ≥ 32 |
Session Management | Essential | HttpOnly cookies, Secure flag, SameSite=Strict, 15-30min timeout | Browser dev tools → Application → Cookies |
Password Policy | Essential | 8+ chars, complexity, bcrypt cost ≥ 12, account lockout | Test weak passwords, verify hashing |
Multi-Factor Auth | Important | TOTP support, backup codes, recovery options | Test MFA flow end-to-end |
OAuth Integration | Important | PKCE implementation, state validation, scope limits | Verify OAuth flow security |
Role-Based Access | Advanced | RBAC system, server-side checks, least privilege | Test role escalation attempts |
# Quick Authentication Audit
echo "Checking JWT secret length..."
echo $JWT_SECRET | wc -c
echo "Checking for hardcoded secrets..."
grep -r "secret\|password\|key" . --exclude-dir=node_modules
API Security Measures
Input Validation
Essential: All endpoints use Zod schemas
- Request payload validation
- Query parameter validation
- File upload restrictions
- Headers validation
Verification: grep -r "z\." app/api/
Rate Limiting
Essential: Prevent abuse and DoS attacks
- General: 100 requests/15 minutes
- Auth endpoints: 5 requests/15 minutes
- Progressive delays for violations
- Distributed rate limiting
Test: Use curl/postman to exceed limits
Injection Prevention
Critical: Stop malicious code execution
- Parameterized queries only
- ORM with proper escaping
- NoSQL injection prevention
- Command injection safeguards
Verification: Code review all DB queries
Data Protection & Privacy
Encryption Assessment
Data Type | At Rest | In Transit | Verification Method |
---|---|---|---|
User passwords | bcrypt hashed | HTTPS only | Check database schema |
PII data | AES-256 encrypted | TLS 1.2+ | Field encryption middleware |
Session tokens | Secure storage | HttpOnly cookies | Browser dev tools check |
API communications | N/A | Certificate pinning | Network traffic analysis |
Privacy Compliance
GDPR/CCPA Requirements:
- Data retention policies (auto-deletion)
- Right to deletion (user request handling)
- Data portability (export functionality)
- Privacy policy accessibility
- Consent management
Data Exposure Prevention:
- Sanitize all log outputs
- Environment variable security
- Error message sanitization
- Source map protection in production
-- Verify encrypted fields in database
SHOW COLUMNS FROM users WHERE Field LIKE '%encrypted%';
SELECT COLUMN_NAME, COLUMN_TYPE FROM information_schema.COLUMNS
WHERE TABLE_NAME = 'users' AND COLUMN_NAME LIKE '%encrypted%';
Client-Side Security
Content Security Policy
Status: Strict CSP implementation required
Production Requirements:
- No 'unsafe-inline' directives
- Nonce-based script execution
- Violation reporting enabled
- Regular violation analysis
Test: curl -I https://your-domain.com | grep -i content-security-policy
XSS Prevention
Input Sanitization: DOMPurify for HTML content, React built-in protections, No unsafe dangerouslySetInnerHTML, User content escaping
Output Encoding: Context-appropriate encoding, Template auto-escaping, JSON response sanitization
Infrastructure Security
Component | Security Measure | Implementation | Monitoring |
---|---|---|---|
Dependencies | Vulnerability scanning | npm audit, Dependabot, Renovate | Weekly scans, alerts |
Environment | Secrets management | No hardcoded secrets, vault storage | Secret rotation logs |
Build Pipeline | Supply chain security | Reproducible builds, artifact scanning | Build integrity checks |
Deployment | Container security | Non-root user, minimal base image | Runtime security scanning |
Monitoring | Security events | Failed auth tracking, anomaly detection | Real-time alerts, dashboards |
# Infrastructure Security Audit Commands
echo "Dependency vulnerabilities..."
npm audit --audit-level high
echo "Environment security..."
grep -r "API_KEY\|SECRET\|PASSWORD" . --exclude-dir=node_modules --exclude="*.log"
echo "Docker security (if applicable)..."
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy image your-app:latest
Security Testing & Compliance
Automated Testing
Continuous Security Testing:
- SAST (Static Analysis)
- DAST (Dynamic Analysis)
- SCA (Software Composition)
- Security unit tests in CI/CD
Tools: ESLint security rules, Semgrep, OWASP ZAP
Manual Validation
Security Reviews: Code review for security changes, Authentication flow testing, Authorization boundary testing, Third-party integration review
Penetration Testing: Annual third-party testing, Vulnerability disclosure program, Bug bounty considerations
Compliance
Documentation Requirements: Security architecture documentation, Incident response procedures, Security controls inventory, Training materials maintenance
Regulatory Compliance: Industry standards (PCI DSS, HIPAA, SOC 2), Regular compliance audits, Third-party vendor assessments
Security Maturity Assessment
Maturity Level | Description | Key Requirements | Timeline |
---|---|---|---|
Level 1: Basic | Essential security measures | Authentication, HTTPS, input validation, dependency updates | Week 1-2 |
Level 2: Intermediate | Comprehensive protection | MFA, CSP, rate limiting, monitoring, regular testing | Month 1-2 |
Level 3: Advanced | Enterprise-grade security | Zero Trust, threat detection, compliance, security culture | Month 3-6 |
Quick security audit script
Automated Security Check
Use this script to quickly audit your Next.js application for common security issues. Run it regularly in your CI/CD pipeline for continuous security monitoring.
#!/bin/bash
# next-security-audit.sh - Quick security check for Next.js apps
echo "Next.js Security Audit Starting..."
# Check for environment variables
echo "Checking environment configuration..."
if [ -f .env.local ] && grep -q "SECRET" .env.local; then
echo "Warning: Secrets found in .env.local"
fi
# Check dependencies
echo "Checking dependencies..."
npm audit --audit-level high || echo "High-severity vulnerabilities found"
# Check for common security issues
echo "Scanning for security patterns..."
grep -r "dangerouslySetInnerHTML" . --exclude-dir=node_modules || echo "No unsafe HTML usage"
grep -r "eval(" . --exclude-dir=node_modules && echo "Eval usage found"
grep -r "innerHTML" . --exclude-dir=node_modules && echo "innerHTML usage found"
# Check CSP headers
echo "Checking security headers..."
if command -v curl &> /dev/null; then
curl -I localhost:3000 2>/dev/null | grep -i "content-security-policy" || echo "CSP header missing"
fi
echo "Security audit complete. Review warnings above."
Security maturity assessment
Track your security implementation progress through these maturity levels:
Level 1: Basic Security (Essential)
- Authentication system implemented
- HTTPS enabled everywhere
- Basic input validation in place
- Regular dependency updates
- Environment variables secured
Level 2: Intermediate Security (Recommended)
- Multi-factor authentication enabled
- Content Security Policy implemented
- Rate limiting active on all endpoints
- Security monitoring and alerting
- Regular security testing
Level 3: Advanced Security (Best Practice)
- Zero Trust architecture principles
- Advanced threat detection and response
- Regular third-party penetration testing
- Compliance framework adherence
- Security-first development culture
Best practices for Cursor AI to build your startup 10x faster
Learn how to use Cursor editor effectively in monorepo including web (Next.js), mobile (React Native), and extension (Vite) apps. Save almost 20 hours of coding time per week.
Envin - type-safe environment variable validation tool with live previews
Say goodbye to runtime environment variable errors. Envin provides framework-agnostic, type-safe validation with real-time previews and an interactive CLI that works with Next.js, Vite, Astro, and any JavaScript framework.