Next.js Security: A Comprehensive Guide how to secure your Next.js application

A comprehensive guide to securing your Next.js application, focusing on practical strategies to protect your application and user data from common vulnerabilities.

In this comprehensive guide, we'll explore best practices for building secure Next.js applications, focusing on practical strategies to protect your application and user data from common vulnerabilities.

Key Learning Objectives:

  • Understanding critical Next.js security principles
  • Implementing robust data validation
  • Configuring Content Security Policy (CSP)
  • Applying authorization patterns correctly
  • Avoiding common security pitfalls

Chapter Overview: Building a Security Mindset

We'll dive deep into: • Server vs Client Security Boundaries • Data Validation Techniques • Content Security Policy Implementation • Authorization Patterns • Environment Variable Management

1. Understanding Server/Client Boundaries in Next.js

The boundary between server and client components is a fundamental concept in Next.js.

The (valid) criticism of the App Router is that it makes it too easy to mix server and client code, leading to security issues, since it's not always clear where the boundary is.

The Golden Rule: Server Data Stays on the Server

Leaking sensitive data with Next.js can be a serious issue, and unfortunately it's easy to do. Below is an example of how to leak sensitive data to the client:

// ❌ UNSAFE: Passing sensitive data to a client component
async function ServerComponent() {
const config = {
apiKey: process.env.API_KEY,
storeId: process.env.STORE_ID,
};
const data = await fetchData(config);
// This passes the entire config (including API key) to the client!
return <ClientComponent data={data} config={config} />;
}
// ✅ SAFE: Keeping sensitive data server-side
import 'server-only';
async function ServerComponent() {
const config = {
apiKey: process.env.API_KEY,
storeId: process.env.STORE_ID,
};
const data = await fetchData(config);
// Only pass what the client needs
return <ClientComponent data={data} publicStoreId={config.storeId} />;
}

Key Insights:

  • What goes in client components will be passed to the client, so be careful what you pass in
  • Use the server-only package to prevent running server-side code on the client
  • Define separate entry points for server and client code
  • Consider using the experimental Taint API for sensitive data to prevent leaking secrets (see below)

2. Using the Taint API

The Taint API is a new experimental feature in React/Next.js that allows you to mark data as sensitive and prevent it from being exposed to the client.

From your root layout, apply the taint API to all sensitive data:

taintUniqueValue(
'The API secret is only used on the server',
process,
process.env.API_SECRET,
);

To test this works, try passing the value to a client component:

// ❌ UNSAFE
const key = process.env.API_SECRET;
return <ClientComponent key={key} />;

The above will throw an error! This is good, because it means the value is not being exposed to the client.

Unfortunately this may not work if the key gets mutated somehow:

taintUniqueValue(
'The API secret is only used on the server',
process,
process.env.API_SECRET,
);
const key = process.env.API_SECRET.toUpperCase();
return <ClientComponent key={key} />;

The above will not throw an error, because the value is not tainted. While this is a great start, it's not a complete solution - and you must be careful to not mutate the value.

3. Data Validation: Trust Nothing, Validate Everything

All user-provided data must be validated before use. This includes:

  • URL parameters
  • Search parameters
  • Form data
  • Cookies
  • API payloads

Implementing Robust Validation with Zod

Zod provides type-safe validation that integrates seamlessly with TypeScript:

import { z } from "zod";
// Define your schema
const userSchema = z.object({
id: z.string().uuid(),
name: z.string().min(2).max(100),
email: z.string().email(),
role: z.enum(["admin", "user", "editor"]),
});
// Validate incoming data
try {
const validatedUser = userSchema.parse(incomingData);
// Safe to use validatedUser
} catch (error) {
// Handle validation errors
}

Validating Server Actions

When using Server Actions, leverage utility functions to enhance with validation. In Makerkit, we have a utility function called enhanceAction that can be used to enhance a server action with validation.

'use server';
import { enhanceAction } from "@kit/next/actions";
import { z } from "zod";
const UpdateProfileSchema = z.object({
name: z.string().min(2).max(100),
bio: z.string().max(500).optional(),
});
export const updateProfileAction = enhanceAction(async (parsedInput, user) => {
// parsedInput is validated, user is authenticated
await db.users.update({
where: { id: user.id },
data: parsedInput
});
return { success: true };
}, {
schema: UpdateProfileSchema,
auth: true, // Requires authentication
});

If you don't use something similar, you can still easily use Zod to validate the data:

import { z } from "zod";
const UpdateProfileSchema = z.object({
name: z.string().min(2).max(100),
bio: z.string().max(500).optional(),
});
export const updateProfileAction = async (parsedInput, user) => {
// parsedInput is validated against UpdateProfileSchema
const { name, bio } = UpdateProfileSchema.parse(parsedInput);
// perform any other authorization checks here
const isAuthorized = await checkAuthorization(user);
if (!isAuthorized) {
throw new Error('Unauthorized');
}
await db.users.update({
where: { id: user.id },
data: { name, bio },
});
return { success: true };
};

Validating API Routes

We have a utility function called enhanceRouteHandler that can be used to enhance an API route handler with validation.

import { enhanceRouteHandler } from "@kit/next/routes";
import { z } from "zod";
const SearchQuerySchema = z.object({
query: z.string().min(1).max(100),
filters: z.array(z.string()).optional(),
page: z.number().int().positive().default(1),
});
export const GET = enhanceRouteHandler(async ({ parsedInput, user }) => {
// parsedInput is validated against SearchQuerySchema
const results = await searchService.search(parsedInput, user);
return Response.json(results);
}, {
schema: SearchQuerySchema,
auth: true,
});

Similar to the enhanceAction function, this will validate the input against the schema and throw an error if it's invalid.

4. Content Security Policy: Your First Line of Defense

Content Security Policy (CSP) helps prevent XSS attacks by controlling which resources can be loaded.

A strict CSP setup will prevent a variety of attacks, including XSS, clickjacking, and other injection attacks. These are some of the most common attacks in web security and CSP is a powerful tool to prevent them - so do consider using it.

Implementing CSP in Next.js

Nosecone is a library by ArcJet that can help you implement CSP in your Next.js application as a middleware.

Below is an example of how to implement it in your middleware:

import * as nosecone from "@nosecone/next";
const config: nosecone.NoseconeOptions = {
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: [
"'self'",
"'strict-dynamic'",
// Add nonce handling for inline scripts
],
connectSrc: [
"'self'",
"https://your-api.example.com",
// Other allowed domains
],
imgSrc: ["'self'", "https://images.example.com", "data:"],
styleSrc: ["'self'", "'unsafe-inline'"], // Consider stricter rules
fontSrc: ["'self'", "https://fonts.googleapis.com"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
frameAncestors: ["'none'"],
},
},
};
const noseconeMiddleware = nosecone.createMiddleware(
process.env.VERCEL_ENV === "preview"
? nosecone.withVercelToolbar(noseconeConfig)
: noseconeConfig,
);
export default noseconeMiddleware;

If you have a more complex middleware, you can use the following pattern:

import * as nosecone from "@nosecone/next";
export async function middleware(request: NextRequest) {
const response = await nosecone.createMiddleware(request);
// Add your custom middleware logic here
// or modify the response as needed
return response;
}

If you use Makerkit, you can enable this setup using the following environment variable:

ENABLE_STRICT_CSP=true

While disabled by default, this will enforce a strict CSP policy and add a nonce to the response headers.

Creating a Nonce from the Nosecone middleware

Nosecone does not automatically create a nonce for you, so you need to do it manually. Below is an example of how to do it:

if (response) {
const contentSecurityPolicy = response.headers.get(
'Content-Security-Policy',
);
const matches = contentSecurityPolicy?.match(/nonce-([\w-]+)/) || [];
const nonce = matches[1];
// set x-nonce header if nonce is found
// so we can pass it to client-side scripts
if (nonce) {
response.headers.set('x-nonce', nonce);
}
}

In the middleware, we added a nonce to the response headers. We can now use this nonce in server components and pass it down to scripts so that they can be executed as per the CSP rules. Any scripts that do not have a nonce will not be executed and this will prevent XSS attacks.

Using Nonces for Inline Scripts

For any inline scripts, add a nonce to make them executable within strict CSP:

import { headers } from "next/headers";
export default async function Layout({ children }) {
const headersStore = await headers();
const nonce = headersStore.get("x-nonce");
return (
<html>
<head>
{/* Only allowed due to nonce */}
<script nonce={nonce} src="https://analytics.example.com/script.js"></script>
</head>
<body>{children}</body>
</html>
);
}

5. Environment Variable Management

Environment variables are a fundamental part of your application. They can be used to store sensitive data that should not be exposed to the client - however, there are some gotchas you must be aware of to avoid exposing sensitive data to the client.

The NEXT_PUBLIC_ Rule

The NEXT_PUBLIC_ prefix is used to make environment variables available to the client. However, it's important to note that this does not make the variable available to the server.

# ❌ NEVER DO THIS - exposed to client
NEXT_PUBLIC_API_KEY=sk_live_1234567890
# ✅ DO THIS - only available server-side
API_KEY=sk_live_1234567890

Imagine your API_KEY is a secret - and must not be exposed to the client. If you use the NEXT_PUBLIC_ prefix, it will be exposed to the client - which creates a serious security risk. Instead, you should use the following:

# ✅ DO THIS - only available server-side
API_KEY=sk_live_1234567890

In addition, remember to never commit your .env file to version control - instead, use a .env.local file for local development, which is ignored by git (but double check this is configured in your .gitignore file).

Best Practices for Environment Variables:

In general, you should follow these best practices:

  1. Use NEXT_PUBLIC_ only for genuinely public information
  2. Store sensitive data in .env.local (which should be gitignored)
  3. Use environment-specific files for values that differ between environments:
    • .env.development for development settings
    • .env.production for production settings
    • .env.test for test settings

5. Authorization: Securing Access to Data and Features

Authorization is the process of determining whether a user has access to a resource.

Don't Rely Solely on Middleware

While Next.js middleware is powerful, it's been subject to security bugs in the past that have allowed it to be bypassed. Never make it your only line of defense.

// ❌ UNSAFE: Relying only on middleware for authorization
export default middleware(async (req) => {
const session = await getSession(req);
if (!session) {
return NextResponse.redirect('/login');
}
// Proceed with the request
});
// ✅ SAFER: Double-check authorization in the route handler too
export async function GET(request) {
const session = await getSession();
if (!session) {
return new Response('Unauthorized', { status: 401 });
}
// Process request knowing we've verified auth again
}

Row Level Security: Authorization at the Data Layer

For applications using Supabase or similar database tools, Row Level Security (RLS) provides an excellent additional security layer by enforcing authorization rules at the database level.

-- Enable RLS on the table
ALTER TABLE public.documents ENABLE ROW LEVEL SECURITY;
-- Create policy for accessing documents
CREATE POLICY "Users can only access their own documents"
ON public.documents
FOR ALL
USING ((select auth.uid()) = user_id);

Key Insights:

  • RLS keeps authorization logic close to the data
  • It works even if application code has bugs
  • It provides defense-in-depth against authorization bypass

In general, you always want to use a verification such as data is validated near where it's used (fetched or mutated), not far away from it (for example, in a middleware).

6. Secure Coding Patterns for Next.js

Safe Data Fetching

// ✅ SAFE: Create a service that keeps secrets server-side
import 'server-only';
export async function fetchUserData(userId) {
// API keys stay server-side
const apiKey = process.env.API_KEY;
const response = await fetch('https://api.example.com/users/' + userId, {
headers: {
'Authorization': `Bearer ${apiKey}`,
},
});
// Return sanitized data only
const data = await response.json();
return {
id: data.id,
name: data.name,
email: data.email,
// Don't return sensitive fields
};
}

Handling Forms Securely

When using forms, it's a good pattern to use Zod to validate the data both on the client and server. While on the client it serves more as a UX tool, on the server it ensures that the data is valid.

'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { submitContactForm } from '@/actions/contact';
const ContactSchema = z.object({
email: z.string().email(),
message: z.string().min(10).max(1000),
});
export function ContactForm() {
const form = useForm({
resolver: zodResolver(ContactSchema),
defaultValues: { email: '', message: '' },
});
return (
<form action={submitContactForm}>
{/* Form fields with validation */}
</form>
);
}

7. Consider MFA for elevated access

MFA (Multi-Factor Authentication) is a powerful way to secure your application. It adds an additional layer of security to your application by requiring a second form of authentication.

When allowing your users to perform actions that have security or high-impact implications, consider requiring MFA or other elevated access controls.

In Makerkit, we have various tools that can help you implement MFA, such as the OTP API. This API allows you to send OTPs to your users and verify them to perform highly destructive actions, such as deleting a personal account or a team.

There are a range of other ways to implement MFA, and it's a good idea to consider what level of security you need for your application.

8. Creating a Security Checklist for Your Next.js App

Before deploying to production, ensure your application meets these security requirements:

  • [ ] Server-side secrets are never exposed to the client
  • [ ] All user inputs are validated with Zod or similar
  • [ ] Content Security Policy is implemented
  • [ ] HTTPS is enforced
  • [ ] Authentication is properly implemented
  • [ ] Authorization is checked at multiple levels (not just middleware)
  • [ ] Environment variables are correctly managed
  • [ ] Dependencies are regularly updated and audited
  • [ ] XSS vulnerabilities are mitigated
  • [ ] CSRF protection is in place
  • [ ] MFA is considered for elevated access

Conclusion

Building secure Next.js applications requires a multi-layered approach. By understanding the server/client boundary, implementing proper data validation, configuring CSP, managing environment variables correctly, and ensuring authorization at multiple levels, you can significantly reduce the risk of security vulnerabilities.

Remember that security is not a one-time task but an ongoing process. Regularly review and update your security measures as new threats emerge and your application evolves.

By following these best practices, you'll create not just functional, but fundamentally secure Next.js applications.