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.
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 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>;
/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'
Define what the response will be - either an error message or a success message.
type RegisterResponse = {
error?: string;
success?: string;
};
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
})
],
});
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);
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."
};
}
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') || ''
};
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."
};
}
}
/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'
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
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>>({})
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)
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)
}
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>
)
}
/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>
)
}
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.
Console output showing the form submissions.
Using an HTTP proxy tool, we can test the validation performed server-side:
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.
Top comments (0)