✨ Become an Affiliate (50%)!

Complete Next.js security guide 2025: authentication, API protection & best practices

·44 min read

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:

  1. Server-Side Rendering (SSR): Code execution on the server before sending HTML to clients
  2. Static Site Generation (SSG): Pre-built pages that can expose build-time data
  3. API Routes: Backend functionality within the same codebase
  4. Client-Side Navigation: Dynamic routing that happens in the browser
  5. 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 types
  • X-Frame-Options: DENY - Prevents your site from being embedded in iframes
  • X-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:

  1. Use strong secrets - JWT_SECRET should be at least 32 characters
  2. Set expiration times - Don't make tokens last forever
  3. Store in httpOnly cookies - Safer than localStorage
  4. 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:

  1. User clicks "Login with Google"
  2. Google verifies their identity
  3. Google sends them back to your app with a code
  4. You exchange the code for user information
  5. 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:

  1. Validate everything - Never trust user input
  2. Fail fast - Check data before processing it
  3. Clear error messages - Help users fix their mistakes
  4. Transform data - Clean and normalize input automatically
  5. 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 AreaPriorityImplementationValidation
JWT SecurityEssential32+ char secrets, secure storage, environment separationecho $JWT_SECRET | wc -c ≥ 32
Session ManagementEssentialHttpOnly cookies, Secure flag, SameSite=Strict, 15-30min timeoutBrowser dev tools → Application → Cookies
Password PolicyEssential8+ chars, complexity, bcrypt cost ≥ 12, account lockoutTest weak passwords, verify hashing
Multi-Factor AuthImportantTOTP support, backup codes, recovery optionsTest MFA flow end-to-end
OAuth IntegrationImportantPKCE implementation, state validation, scope limitsVerify OAuth flow security
Role-Based AccessAdvancedRBAC system, server-side checks, least privilegeTest 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 TypeAt RestIn TransitVerification Method
User passwordsbcrypt hashedHTTPS onlyCheck database schema
PII dataAES-256 encryptedTLS 1.2+Field encryption middleware
Session tokensSecure storageHttpOnly cookiesBrowser dev tools check
API communicationsN/ACertificate pinningNetwork 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

ComponentSecurity MeasureImplementationMonitoring
DependenciesVulnerability scanningnpm audit, Dependabot, RenovateWeekly scans, alerts
EnvironmentSecrets managementNo hardcoded secrets, vault storageSecret rotation logs
Build PipelineSupply chain securityReproducible builds, artifact scanningBuild integrity checks
DeploymentContainer securityNon-root user, minimal base imageRuntime security scanning
MonitoringSecurity eventsFailed auth tracking, anomaly detectionReal-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 LevelDescriptionKey RequirementsTimeline
Level 1: BasicEssential security measuresAuthentication, HTTPS, input validation, dependency updatesWeek 1-2
Level 2: IntermediateComprehensive protectionMFA, CSP, rate limiting, monitoring, regular testingMonth 1-2
Level 3: AdvancedEnterprise-grade securityZero Trust, threat detection, compliance, security cultureMonth 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
  • 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

On this page

Why Next.js security matters more than ever
Understanding the Next.js security landscape
The unique security challenges of Next.js applications
Common Next.js security vulnerabilities
1. Cross-site scripting (XSS) attacks
2. Cross-site request forgery (CSRF)
3. Authentication and authorization flaws
4. API route security issues
5. Dependency vulnerabilities
Next.js App Router security: securing modern applications
Securing Server Actions (step-by-step guide)
Define your validation schema
Create your secure server action
Server component security patterns
Secure client/server boundaries
App Router middleware security
Route groups and layout security
Implementing robust authentication in Next.js
NextAuth.js: the gold standard for Next.js authentication
JWT implementation in Next.js: best practices
OAuth implementation strategies
Securing Next.js API routes
Authentication middleware for API routes
Rate limiting implementation
Input validation and sanitization with Zod
CSRF protection in Next.js
XSS prevention strategies
Content security policy implementation
CSP testing and monitoring
Safe HTML rendering
Next.js middleware for security
Environment variables security
Database security with Next.js
Security testing and monitoring
Deployment security considerations
Vercel deployment security
Docker security configuration
Role-based access control implementation
The ultimate Next.js security audit checklist
Authentication Security Audit
API Security Measures
Rate Limiting Configuration
File Upload Security
Data Protection & Privacy
Encryption Assessment
Privacy Compliance
Client-Side Security
Infrastructure Security
Security Testing & Compliance
Security Maturity Assessment
Quick security audit script
Security maturity assessment
Level 1: Basic Security (Essential)
Level 2: Intermediate Security (Recommended)
Level 3: Advanced Security (Best Practice)
world map
Community

Connect with like-minded people

Join our community to get feedback, support, and grow together with 100+ builders on board, let's ship it!

Join us

Ship your startup everywhere. In minutes.

Don't spend time on complex setups. TurboStarter simplifies everything.