Next-level security: how to hack-proof your Next.js applications

Raí Siqueira
December 5, 2024

The increasing complexity of modern frontend applications requires developers to give more thought to security in web applications. Frameworks like Next.js offer powerful features that enhance user experiences, but they also come with peculiar security challenges that developers must address.

Next.js, being a hybrid framework for both static and server-side rendering, offers features like Server-Side Rendering (SSR), Static Site Generation (SSG), and API routes, which can be leveraged to build secure applications. However, it’s crucial for developers to implement best practices around these features to effectively mitigate security risks.

As both React and Next.js continue to evolve, Server Actions and Server Components are becoming central to the future of these frameworks, by enabling more secure and efficient ways of handling sensitive logic. In this blog post, we'll explore best practices for securing applications built on Next.js and leveraging the emerging power of Server Actions.

Authentication Best Practices in Next.js

Authentication is a critical aspect of any application, and it's essential to implement it securely in Next.js to protect user data. One commonly used package for authentication in Next.js is AuthJS, formerly known as next-auth. It provides a flexible and easy-to-use authentication system that integrates well with Next.js.

One common practice when using AuthJS is to use secure cookies for session management. When managing user sessions, it's essential to use HTTP-only and secure cookies to prevent XSS attacks. This ensures that session cookies cannot be accessed via client-side JavaScript and are only transmitted over HTTPS.

XSS allows hackers to inject malicious JavaScript into a web application. Such injections are extremely dangerous from the security perspective, and can lead to:

  • Stealing sensitive information, including session tokens, cookies, or user credentials;
  • Changing the website appearance to trick users into performing undesirable actions.

Example of an AuthJS configuration for secure cookies

// <root>/auth.ts
import NextAuth from 'next-auth/next';

const authConfig = NextAuth({
  providers: [
    // Add providers here,
  ],
  session: {
    strategy: 'jwt',
    maxAge: 60 * 60 * 24 * 30,
    updateAge: 60 * 60 * 24,
  },
  cookies: {
    sessionToken: {
      name: 'next-auth.session-token',
      options: {
	 // httpOnly prevents client-side JavaScript from accessing the cookie.
        httpOnly: true,
 // Use secure cookies in production
	 secure: process.env.NODE_ENV === 'production',
        sameSite: 'lax',
        path: '/',
      }
    }
  }
})

export const { handlers, auth, signIn, signOut } = authConfig;

Always remember that storing sessions in localStorage is less secure than using http-only cookies. This is because localStorage is accessible to JavaScript, making it vulnerable to XSS attacks. In contrast, http-only cookies are inaccessible to client-side scripts, providing better protection for sensitive session data

For robust security, it's recommended to store session tokens in http-only cookies, especially when working with authentication libraries like AuthJS.

Managing permissions

Handling permissions in Next.js applications is essential for ensuring that only authorized users can access specific resources or perform particular actions. The goal is to enforce fine-grained access control across different parts of the app, ensuring users only see what they’re allowed to see and perform actions based on their permissions.

Common approaches to handling permissions include Role-Based Access Control (RBAC), as well as Permission-Based Access Control (PBAC). In RBAC, you assign roles (for example, "admin", "user", "editor") to users, and each role has a set of permissions. In PBAC, you assign specific permissions (for example, config:read or event:write) directly to the user.

Let's use the AuthJS package to demonstrate how to protect server-side logic by verifying user permissions with PBAC on a pages API router.

// pages/api/todos.ts
import { getSession } from 'next-auth/react';

export default async (req, res) => {
  const session = await getSession({ req });

  if (!session || !session.user.permissions.includes('todos:write')) {
    return res.status(403).json({ message: 'Forbidden' });
  }

  // Handle authorized event editing
  res.status(200).json({ message: 'Todo updated' });
}

You can check more about this topic in the Auth.js documentation.

An important note when creating server functions or server actions: they are public by default, meaning anyone can access them if not explicitly protected. This often happens in Next.js development because developers may overlook security in favor of quick implementations.

Always ensure you verify permissions or authentication to avoid unintentional access to sensitive operations, especially when working in fast-paced environments like Next.js, where convenience can lead to oversights.

Handling user data responsibly

When working with user data in any application, it’s crucial to implement strict security measures to protect privacy and prevent unauthorized access. User data should always be handled carefully, adhering to data protection principles such as minimization (only collecting necessary data).

Additionally, sensitive data like passwords should never be stored or transmitted in plaintext, and any data stored on the client should be kept secure through mechanisms like HTTP-only cookies.

React has introduced an experimental feature called experimental_taintObjectReference to enhance security. This flag is also available in Next.js since version 14.

This feature helps developers track the flow of potentially sensitive data throughout their applications. It is useful in preventing accidental exposure of sensitive user data by "tainting" objects and throwing errors when tainted data is passed to unsafe locations (e.g., the client-side).

This acts as an additional safeguard to ensure that sensitive information, like tokens or personal identifiers, never gets leaked to the client. Here is a simple example of how the taint objects work on Next.js:

// next.config.js
const nextConfig = {
  experimental: {
    taint: true,
  },
};

export default nextConfig;
// app/get-user-data.ts
import { experimental_taintObjectReference } from 'react';

export async function getUserData(id) {
  const data = ...;
  experimental_taintObjectReference(
    'Do not pass user data to the client',
    data
  );
  return data;
}
// app/page.tsx
import { getUserData } from './data';
 
export async function Page({ searchParams }) {
  const userData = await getUserData(searchParams.id);
  return <ClientComponent user={userData} />; // error
}

So, when trying to pass down the userData value, it will raise an error. Instead of passing down all the user data, you can extract only those values that really are relevant for the client component:

export async function Page({ searchParams }) {
  const { name, phone } = await getUserData(searchParams.id);
  // Intentionally exposing personal data
  return <ClientComponent name={name} phoneNumber={phone} />;
}

You can read more about the experimental taintObjectReference in the React documentation.

Prevent Cross-Site Request Forgery (CSRF)

CSRF attacks exploit authenticated users by tricking them into executing unwanted actions (e.g., form submissions) on their behalf. Without proper protection, users can unintentionally compromise their accounts and data, or execute unwanted operations on their behalf

A solution for that is to use CSRF tokens to validate that every state-changing request comes from your application and not from an external malicious source. Server Actions should always be treated as hostile, and input must be verified (e.g.: using a library like Zod or Valibot), and data sanitization is crucial.

You can use a third-party library that helps to implement this sort of CSRF protection on Next.js applications. For this, let's use the @edge-csrf/nextjs package to protect against CSRF attacks. The implementation is straightforward, basically update (or create, if it doesn’t exist) the middleware file:

// middleware.ts
import { CsrfError, createCsrfProtect } from '@edge-csrf/nextjs';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

const csrfProtect = createCsrfProtect({
  cookie: {
    secure: process.env.NODE_ENV === 'production',
  },
});

export async function middleware(request: NextRequest) {
  const response = NextResponse.next();

  // csrf protection
  try {
    await csrfProtect(request, response);
  } catch (err) {
    if (err instanceof CsrfError) return new NextResponse('invalid csrf token', { status: 403 });
    throw err;
  }
  return response;
}

With this implementation, we will be able to get the CSRF token, and use it in your forms:

// app/page.ts
import { revalidatePath } from 'next/cache';
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';

export default function Page() {
  const csrfToken = headers().get('X-CSRF-Token') || 'missing';

  async function myAction() {
    'use server';
    console.log('passed csrf validation');
    revalidatePath('/');
    redirect('/');
  }

  return (
    <>
      <p>
        CSRF token value:
        {csrfToken}
      </p>
      <h2>Server Action Form Submission Example:</h2>
      <p>NOTE: Look at browser network logs and server console for submission feedback</p>
      <form action={myAction}>
        <legend>Form without CSRF (should fail):</legend>
        <input type="text" name="input1" />
        <button type="submit">Submit</button>
      </form>
      <br />
      <form action={myAction}>
        <legend>Form with incorrect CSRF (should fail):</legend>
        <input type="hidden" name="csrf_token" value="notvalid" />
        <input type="text" name="input1" />
        <button type="submit">Submit</button>
      </form>
      <br />
      <form action={myAction}>
        <legend>Form with CSRF (should succeed):</legend>
        <input type="hidden" name="csrf_token" value={csrfToken} />
        <input type="text" name="input1" />
        <button type="submit">Submit</button>
      </form>
    </>
  );
}

One important note here is that all HTTP submission requests (e.g. POST, PUT, DELETE, PATCH) will be rejected if they do not include a valid CSRF token. To help with this, you can create a wrapper component that includes the CSRF token, and use this wrapper component in all your forms, here is an example:

import type { FormHTMLAttributes, PropsWithChildren } from 'react'

import { headers } from 'next/headers'

type CSRFFormProps = PropsWithChildren<FormHTMLAttributes<HTMLFormElement>>

const CSRFForm = ({ children, ...rest }: CSRFFormProps) => {
  const csrfToken = headers().get('X-CSRF-Token') || 'missing'
  return (
    <form {...rest}>
      <input type="hidden" name="csrf_token" value={csrfToken} />
      {children}
    </form>
  )
}

export { CSRFForm }

Now, just wrap your form in this component, so all of your forms will have the CSRF token whenever you submit an action.

Next.js 14 has an additional protection against CSRF that compares the Origin header to the Host header. If they don't match, the action will be rejected. It’s important to highlight that users relying on outdated browsers without support for the Origin header may be vulnerable to security risks.

Safe Server Actions on Next.js

Server Actions in Next.js allow you to define functions that run on the server, enabling seamless interaction with server-side logic without API routes. When building applications with Next.js, one common pitfall is unintentionally exposing sensitive server-side logic to the client.

Server Actions, if not properly secured, can inadvertently expose private data such as database queries, API keys, or environment variables. This can occur if sensitive operations or resources are returned to the client or mishandled in the server response. It's crucial to carefully manage the data sent back to the browser to avoid security vulnerabilities like data leakage.

To ensure security, Server Actions must be designed with proper input validation, access control, and an emphasis on keeping sensitive operations strictly server-side.

Here’s an example of an unsafe Server Action that manipulates the user's to-do tasks:

// app/tasks/add-task-action.ts
'use server';
import { db } from '@/database';

const addTaskAction = async (formData: FormData) => {
  const title = formData.get('title');
  await db.task.insert({
     data: { title },
  })
}
// app/tasks/page.tsx
import { addTaskAction } from './add-task-action';

const Tasks = () => {
  return (
    <form action={addTask}>
      <input type="text" name="title" />
      <button type="submit" onClick={() => {
   addTaskAction();
      }}>Create task</button>
    </form>
  );
}
export default Tasks;

In the example above, the Server Action handles a sensitive layer of the application - database interactions. By adding validation and authentication checks, we can ensure that only authorized users can save tasks:

// app/tasks/add-task-action.ts
'use server';
import { db } from '@/database';

const addTask = async (formData: FormData) => {
  const user = await getSession();
  if (!user || !user.isAuthenticated) {
    throw new Error('Unauthorized');
  }
  const title = formData.get('title');
  await db.task.insert({
     data: { title },
  })
}

We can improve this action even more by combining two third-party libraries: next-safe-actions and Zod. This combo will help us write type-safe actions, and with the next-safe-actions, we can use middleware to check if the user exists before performing any data mutation:

// @/safe-action.ts
import { createSafeActionClient, DEFAULT_SERVER_ERROR_MESSAGE } from 'next-safe-action';
const actionClient = createSafeActionClient({
  handleReturnedServerError: (error: unknown) => {
    if (error instanceof Error) {
      return error.message;
    }
    return DEFAULT_SERVER_ERROR_MESSAGE;
  }
});

const authActionClient = actionClient.use(async ({ next }) => {
  const session = await getSession();
  if (!session) {
    throw new Error("Unauthorized");
  }
  return next({ ctx: { session } });
});

export { actionClient, authActionClient };

With this basic setup, we’re able to create our actions. Let’s improve the add-task-action.ts with the authActionClient:

// app/tasks/add-task-action.ts
import { z } from "zod";
import { db } from '@/database';
import { authActionClient } from "@/safe-action";

const addTaskSchema = z.object({
  title: z.string().min(3),
});

export const addTaskAction = authActionClient.schema(addTaskSchema).action(async ({ parsedInput, ctx }) => {
  const { title } = parsedInput;
  const { session } = ctx;
  const { user } = session;
  const { id } = user;

  const task = await db.task.create({
    data: {
      title,
      userId: id,
    },
  });
});

Now, the add-task-action uses the authActionClient which will check if the user exists before executing the action, also it now includes a schema validation where it will check if the data sent by the client follows the action schema.

You can check out more about the next-safe-action on the documentation page.

Safeguarding sensitive code with the server-only package

The server-only package ensures that certain code or data is only executed on the server side, preventing accidental leakage to the client. This is particularly useful in protecting sensitive operations like database queries, API keys, or user authentication logic.

When using server-only, developers can confidently safeguard actions and resources, avoiding the risk of sensitive code being exposed to the browser or client-side execution. This package aligns with Next.js's goal of providing a clear separation between client and server code for improved security.

import 'server-only';

// Your server only code here.

One important note regarding the server-only package: this is used to ensure that specific code is only executed on the server side. It throws an error if the code is accidentally imported or run on the client side, which helps prevent sensitive logic or data from being exposed to the client.

Do not confuse it with "use server", which is a directive for Server Actions that instructs Next.js to run the specified function on the server, but enables the client-side to call it.

When should I use the server-only package?

If you want to safeguard certain modules from being imported or executed client-side at all, wrap them with import 'server-only'.

When should I use the 'use server' directive?

For Next.js Server Actions, when you need to handle client interactions with logic that should be executed on the server (e.g., handling form submissions, interacting with a database, or performing secure server-side operating after checking permissions).

Limit Side Effects on the Server

Keeping on the Server Actions context, ensure that they are idempotent when necessary. This means if a user accidentally triggers the same action multiple times, it won't have repeated or harmful effects (e.g., double payments).

Use techniques like transaction locking in databases, rate limiting, or request tracking to prevent repeated processing.

Log and monitor Server Actions

Keep detailed logs of who is invoking Server Actions, what data is being passed, and any suspicious activity. This helps track security breaches or abnormal patterns. Libraries like Sentry can help log and monitor error rates and detect potential attacks or misuses.

Optimizing performance and security: running Next.js in production mode

The production mode ensures your application is fully optimized for performance and security. Production mode enables key features like automatic minification of JavaScript, image optimization, and more efficient server-side rendering.

In the Next.js documentation, you can find a checklist with a few steps about security to follow.

Wrap-up

Securing a Next.js application requires developers to adopt a mindful approach toward handling sensitive operations, focusing on topics such as secure authentication, effective permission management, and protecting server-only code.

Additionally, we emphasized the importance of securing Server Actions through input validation, access control, and ensuring idempotency to limit side effects. Tools like Prettier, ESLint, and Biome can also help you catch potential security issues early by enforcing best practices in your code before it is committed, improving both code quality and security.

As frameworks evolve, staying updated with the latest security practices is crucial to protect your users and maintain trust. Remember, security is not a one-time task, but an ongoing process of improving and adapting to new challenges.