Next.js server action security

david-arcjet

David Mytton

Posted on November 25, 2024

Next.js server action security

Next.js server action security

Server actions were introduced in Next.js 13 and marked stable in Next.js 14 as a new way to process data on the server in response to a client interaction. A server action is like an asynchronous API route, but more tightly coupled to the UI so the data mutation code is closer to where it is triggered.

Server actions are an elegant way to handle user actions, such as button clicks or form submissions, because much of the boilerplate is handled for you - they are invoked through automatically generated POST requests. Next.js hides most of the request details so your code can focus just on processing the data.

Next.js server action security
POST request made to a Next.js server action.

The client can call these special functions directly, eliminating the need to create separate API endpoints to handle requests. Instead, the functions are invoked through automatically generated POST requests triggered by user actions. They are now the recommended way to handle form submissions and data mutations in Next.js applications.

However, server actions are just another API. This has led to security problems because they appear like functions within your code, hiding the risks that are more obvious when implementing traditional APIs. In this post, we’ll discuss how to improve the security of your Next.js server actions.

Security implications of server actions

The elegant design of server actions hides the implementation details of handling a normal API request. This makes it easier to create simple functions for common actions like form submissions, but lulls developers into a false sense of security.

All server actions are public HTTP endpoints, which means anyone can make calls to them. You should treat them like any other API endpoint and ensure you handle things like authentication and validation.

Next.js 15 introduced unique, non-deterministic ID references for server actions to make it more difficult to locate and reference the APIs. However, this is just security by obscurity and the endpoint can still be found within the client code or when triggering a request to raw server action.

Unused server actions will not have their IDs exposed to the client-side JavaScript bundle. However, if a user were to get the handle to the ID of an in-use action, they could still be invoked with any arguments.

The action ID can be derived from the Next-Action request header.

Next.js server action security
Next.js action ID revealed by the Next-Action header.

Further, server actions defined in React components are generated as closures, meaning they have access to the parent scope. Data is sent from the client to the server which could expose sensitive information - this is encrypted, but data is still sent unless you use the React Taint APIs.

This also has implications for self-hosting Next.js because the generated encryption keys will be different on each server. You will need to handle syncing the encryption keys to ensure requests that round-robin to different servers work correctly.

POST APIs have built-in protection from CSRF attacks in modern browsers so server actions inherit this by default. However, it is still possible to bypass those protections. To fully protect server actions against CSRF you need to set the experimental allowedOrigins setting in your Next.js config.

These are the architectural security implications. Once you've considered these, you then need to implement usual API security protections, which we'll explore below.

Securing a from submission server action

Once you have considered the security implications described above, you can then proceed to add additional protection by setting up input validation and installing Arcjet.

Arcjet is a security as code product that can protect your Next.js application from bots, form spam, and other common web application attacks. It’s installed as a dependency in your application and can then be configured on a Next.js server action.

To demonstrate, let's create a form component that will be displayed to the user as a page.tsx component. When the user submits the form, it will call a server action function named registerUser in actions.ts. This server action will run on your server, not in the user's browser.

Adopting a defense-in-depth approach, we will also incorporate Arcjet protections to establish rate limiting, protect against common attacks including those in the OWASP Top 10, and block traffic from automated clients and bots. We will also validate data using the Zod library – on both the client and server side.

Installing Zod

To install Zod, ensure strict mode in your tsconfig.json file is set to true. Then run the following command in the project root: npm install zod

/src/app/lib/schema.ts

Let's begin by defining the schema that will be used by both the frontend and backend to validate the form field input.

// Import the Zod library, which helps us validate data.
import { z } from 'zod';

// Create a schema for validating user registration data.
export const registrationSchema = z.object({
  // Email field: must be a valid email format.
  email: z.string().email('Please enter a valid email address'),

  // Password field: has several requirements.
  password: z.string()
    // Must be at least 8 characters long.
    .min(8, 'Password must be at least 8 characters long')
    // Must contain: 
    // - at least one lowercase letter (a-z)
    // - at least one uppercase letter (A-Z)
    // - at least one number (0-9)
    .regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, 
      'Password must contain at least one uppercase letter, one lowercase letter, and one number'),

  // Confirmation password field.
  confirmPassword: z.string()
    .min(8, 'Please confirm your password'),
}).refine(
  (data) => data.password === data.confirmPassword,
  {
    message: 'Passwords do not match',
    path: ['confirmPassword'],
  }
);

// Create a TypeScript type from our schema.
export type RegistrationData = z.infer<typeof registrationSchema>;
Enter fullscreen mode Exit fullscreen mode

/src/app/actions.ts

Start with adding the 'use server' annotation. With this, every exported async function in this file will become a server action.

Then import Arcjet and Zod. Arcjet supports Next.js server actions with the request() utility function, which creates request objects that allow access to the headers for analyzing the request.

'use server'
import arcjet, { shield, detectBot, fixedWindow, request } from '@arcjet/next';
import { registrationSchema, type RegistrationData } from './lib/schema'
Enter fullscreen mode Exit fullscreen mode

Define what the response will be - either an error message or a success message.

type RegisterResponse = {
  error?: string;
  success?: string;
};
Enter fullscreen mode Exit fullscreen mode

Now, configure the rules for the Arcjet protection measures. Shield will detect common attacks, bot detection prevents all automated clients from submitting the form, and the fixed window rate limit will allow 5 submissions per IP address within a 1 minute window - a reasonable limit for a normal form.

const aj = arcjet({
  key: process.env.ARCJET_KEY!,
  rules: [
    shield({
      mode: "LIVE",
    }),
    detectBot({ 
      mode: "LIVE",
      allow: []
    }),
    fixedWindow({
      mode: "LIVE",
      window: "1m",
      max: 5
    })
  ],
});
Enter fullscreen mode Exit fullscreen mode

Next, define the server action function. Start by creating an exported async function named registerUser(). This function will take two arguments:

  • _prevState which stores the previous submission messages.
  • formData which represents the data submitted by the user.

Either the invalid field messages or a success message will be returned depending on the outcome of the validation check.

export async function registerUser(
  _prevState: RegisterResponse,
  formData: FormData
): Promise<RegisterResponse> {
  // Next.js hides the request by default, so this Arcjet
  // utility function gets what's needed to analyze it
  const req = await request();
  const decision = await aj.protect(req);
Enter fullscreen mode Exit fullscreen mode

Then, check if the request should be blocked against the rules with the following code block. If Arcjet detects that it has exceeded the threshold, return an error message.

  if (decision.isDenied()) {
    if (decision.reason.isRateLimit()) {
      return {
        error: "Too many registration attempts. Please try again later."
      };
    }
    if (decision.reason.isBot()) {
      return {
        error: "You are a bot. Please go away."
      };
    }
    return {
      error: "An error occurred during registration."
    };
  }
Enter fullscreen mode Exit fullscreen mode

Next.js server action security
Registration form showing the rate limit error.

Next, create an object named data that will collect the form fields from the submission using the .get() method on the formData object that is sent to this function from the form.

  const data = {
    email: formData.get('email') || '',
    password: formData.get('password') || '',
    confirmPassword: formData.get('confirmPassword') || ''
  };
Enter fullscreen mode Exit fullscreen mode

Parse the data object against the validation schema using the .safeParse() Zod method and store this evaluation in the result variable. If the validation check fails, the fields responsible will display their error messages. If the check passes, the registration to a user database is simulated with a message printed to the terminal and a success message is displayed.

Finally, to handle any unexpected errors, we include the catch block at the end.

  try {
    const result = registrationSchema.safeParse(data);
    if (!result.success) {
      return {
        error: result.error.errors[0].message
      };
    }

    const validatedData: RegistrationData = result.data;

    // This is where you would normally save the user to a database.
    console.log("Database would register:", { email: validatedData.email });

    return {
      success: "Registration successful!"
    };

  } catch (error) {
    console.error('Registration error:', error);
    return {
      error: "An error occurred during registration."
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

/src/app/components/form.tsx

Now, let's create the form component of the webpage. Begin with the 'use client' annotation to specify that this component runs in the browser.

Next, import the necessary hooks, the server action function, and validation schema.

'use client'

// Next.js hook that manages form state and server action responses.
import { useActionState } from 'react'

// React's built-in hook for managing local component state.
import { useState } from 'react'

// Import our server action function that handles form submission.
import { registerUser } from '../actions'

// Import our Zod schema that defines validation rules.
import { registrationSchema } from '../lib/schema'
Enter fullscreen mode Exit fullscreen mode

Define the initial state of the form using undefined values since there are no error or success messages before the first form submission.

const initialState = {
  error: '',
  success: undefined
} as const
Enter fullscreen mode Exit fullscreen mode

At the beginning of the form creation function:

The useActionState hook takes two arguments: the server action function registerUser and the initialState that we just defined. In the tuple, state stores the current error or success messages and formAction is the client-side function that triggers the registerUser server action.

The useState hook creates an object of key-value pairs consisting of the form field names and their respective error messages. In the tuple, validationErrors stores these messages and setValidationErrors is responsible for updating them.

export default function RegisterForm() {
  const [state, formAction] = useActionState(registerUser, initialState)
  const [validationErrors, setValidationErrors] = useState<Record<string, string>>({})
Enter fullscreen mode Exit fullscreen mode

The handleSubmit client-side function runs whenever the form is submitted. Upon a form submission, setValidationErrors({}) clears any previous messages. The form data is collected and stored in the data object variable and compared against the validation schema.

  const handleSubmit = async (formData: FormData) => {
    setValidationErrors({})

    const data = {
      email: formData.get('email')?.toString() || '',
      password: formData.get('password')?.toString() || '',
      confirmPassword: formData.get('confirmPassword')?.toString() || '',
    }

    const result = registrationSchema.safeParse(data)
Enter fullscreen mode Exit fullscreen mode

If validation fails, it displays the errors locally without making a server call. If validation passes, formAction(formData) passes the form data to the server and calls the registerUser server action function.

    if (!result.success) {
      const errors: Record<string, string> = {}
      result.error.errors.forEach((error) => {
        const field = error.path[0].toString()
        errors[field] = error.message
      })
      setValidationErrors(errors)
      return // Stop here - don't submit invalid data.
    }

    await formAction(formData)
  }
Enter fullscreen mode Exit fullscreen mode

The form that will be rendered will call handleSubmit which subsequently calls the server action if validation passes. If the check does not pass, the appropriate error messages will be sourced from validationErrors and displayed to the user.

return (
    <form action={handleSubmit}>
      <div>
        <label htmlFor="email">Email:</label>
        <input 
          type="email" 
          id="email" 
          name="email" 
          required 
        />
        {validationErrors.email && (
          <p>{validationErrors.email}</p>
        )}
      </div>

      <div>
        <label htmlFor="password">Password:</label>
        <input 
          type="password" 
          id="password" 
          name="password" 
          required 
        />
        {validationErrors.password && (
          <p>{validationErrors.password}</p>
        )}
      </div>

      <div>
        <label htmlFor="confirmPassword">Confirm Password:</label>
        <input 
          type="password" 
          id="confirmPassword" 
          name="confirmPassword" 
          required 
        />
        {validationErrors.confirmPassword && (
          <p>{validationErrors.confirmPassword}</p>
        )}
      </div>

      {state?.error && (
        <p>{state.error}</p>
      )}

      {state?.success && (
        <p>{state.success}</p>
      )}

      <button type="submit">Register</button>
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

/src/app/page.tsx

Finally, import the form on the app's landing page.

import RegisterForm from './components/form'

export default function Home() {
  return (
    <main>
      <h1>Register Account</h1>
      <RegisterForm />
    </main>
  )
}
Enter fullscreen mode Exit fullscreen mode

Test the Protections

To test your web application run npm run dev and visit: http://localhost:3000/ .

Submit the form 6 times within a minute to trigger the rate limit.

Next.js server action security
Console output showing the form submissions.

Using an HTTP proxy tool, we can test the validation performed server-side:

Next.js server action security
Using a proxy interceptor to test the form field validation.

Conclusion

While server actions provide a convenient way to tie backend functionality to the client, without special care they can become a security vulnerability.

The protective measures you will need to take to secure these handlers will depend on your specific use. In addition to implementing Arcjet protections and validation, read our Next.js security checklist for more tips. Next.js also has server actions security documentation worth reviewing.

💖 💪 🙅 🚩
david-arcjet
David Mytton

Posted on November 25, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related