aj.protect(req) with Shield, detectBot, and a fixed window before you write to the database.Are Next.js server actions a security risk?
Yes, if you treat them as private functions. A server action is a public HTTP API. Next.js generates a POST for you and hides the route, but anyone who can trigger that POST (or replay it) can invoke the action with any arguments they choose.
The function looks local. The network boundary is still there. Authentication, authorization, input validation, bot detection, attack filtering, and rate limiting belong on a server action for the same reason they belong on a Route Handler. What is runtime application security? is the definition of that in-request check.
Next.js 14 marked server actions stable. Next.js 15 gave each action a unique, non-deterministic ID so the endpoint is harder to guess. That ID is still shipped to the client for every action the page uses, and it appears in the Next-Action request header. Obscurity is not a control. Unused actions stay out of the client bundle, but an ID that is in use can be called again with different data.
What does Next.js hide on a server action?
The framework hides the request object, the URL, and most of the HTTP details so you can write registerUser(formData) next to the form that calls it. That is convenient, and it is why teams skip the checks they would never skip on /api/register.
A server action defined inside a React component is a closure. It can read values from the parent scope. Those values may be sent back to the server so the action can close over them. Next.js encrypts that payload, but the data still leaves the server. Use the React taint APIs when you must keep a value off the wire.
Self-hosting adds a key-management step. The encryption keys Next.js generates are different on each instance unless you sync them. Requests that land on a different replica fail if the keys do not match.
Next.js does its own CSRF check on Server Actions: it compares the request's Origin against the Host (or X-Forwarded-Host) and rejects mismatches. That is the framework doing the work, not a browser default, which matters because it means the check follows your deployment topology. Behind a proxy that rewrites those headers you can get both false rejections and a weakened check, so set experimental.serverActions.allowedOrigins to the origins that may invoke actions.
Those are architectural facts. After you accept them, you still need the same request-path controls you put on any public endpoint.
How do you protect a server action?
Validate the payload on the server, then call a runtime security SDK before you write to the database. Client-side checks improve the form. They do not protect the action, because the action is reachable without the form.
A registration action is a good example: it creates an account, it is public, and it is a favorite target for bots and credential stuffing. The following controls belong on that path:
- Schema validation on the server, with the same schema the form uses, so a proxied
POSTcannot skip the browser. - Shield, to detect SQL injection, cross-site scripting, and other common attack shapes in the request.
- Bot detection, to deny automated clients on a signup form.
- A fixed window rate limit, so one IP cannot submit the form in a tight loop.
For more information about placing those controls in the request path, see how to detect and block attacks at runtime.
What does the Arcjet call look like?
Install @arcjet/next and create a client with shield, detectBot, and fixedWindow. Server actions do not receive a Request, so call request() from @arcjet/next and pass the result to aj.protect(req).
"use server";
import arcjet, { detectBot, fixedWindow, request, shield } from "@arcjet/next";import { registrationSchema } from "./lib/schema";
const aj = arcjet({ key: process.env.ARCJET_KEY!, rules: [ shield({ mode: "LIVE" }), detectBot({ mode: "LIVE", allow: [] }), fixedWindow({ mode: "LIVE", window: "10m", max: 5 }), ],});
type RegisterResponse = { error?: string; success?: string;};
export async function registerUser( _prevState: RegisterResponse, formData: FormData,): Promise<RegisterResponse> { const req = await request(); const decision = await aj.protect(req);
if (decision.isDenied()) { if (decision.reason.isRateLimit()) { return { error: "Too many registration attempts. Try again later.", }; } if (decision.reason.isBot()) { return { error: "Automated signups are not allowed." }; } return { error: "This request was blocked." }; }
const parsed = registrationSchema.safeParse({ email: formData.get("email") || "", password: formData.get("password") || "", confirmPassword: formData.get("confirmPassword") || "", });
if (!parsed.success) { return { error: parsed.error.issues[0]?.message ?? "Invalid input." }; }
// Persist the user here. return { success: "Registration successful." };}detectBot takes allow or deny, never a legacy block list. allow: [] means every detected bot is denied, which is the right default on a registration form. fixedWindow({ window: "10m", max: 5 }) allows five submissions per identifier in a ten-minute window. The default identifier is the client IP (ip.src) unless you set characteristics and pass those values into protect().
Start Shield in DRY_RUN on a busy form if you want to measure false positives against your own traffic, then switch that rule to LIVE.
How do you validate the payload?
Share one schema between the client component and the server action. Zod is a common choice. The server must run safeParse (or the equivalent) even if the client already did, because a proxy can submit the action without the UI.
import { z } from "zod";
export const registrationSchema = z .object({ email: z.string().email("Enter a valid email address"), password: z .string() .min(8, "Password must be at least 8 characters") .regex( /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, "Password must include upper, lower, and a number", ), confirmPassword: z.string().min(8, "Confirm your password"), }) .refine((data) => data.password === data.confirmPassword, { message: "Passwords do not match", path: ["confirmPassword"], });
export type RegistrationData = z.infer<typeof registrationSchema>;On the client, parse the same schema in the submit handler and return field errors without calling the action when the form is invalid. That saves a round trip. It is not a security boundary. The action still validates, and Arcjet still runs first so a flood of invalid posts is limited before you parse.
What do you return on a deny?
Branch on the decision reason and return an action-shaped error, not an uncaught exception. Rate limits are a 429-class event: tell the user to wait. Bot and Shield denials are a 403-class event: do not explain the detector. A server action already returns data to the form, so map those cases onto { error: string } and let the client render the message.
Log the decision in your own telemetry if you need to investigate a false positive. The Arcjet dashboard also keeps request history, which is how you later answer "was this action hit by a scanner last week?"
What else belongs on the action?
Runtime filters do not replace application checks. After protect() allows the request and the schema parses, you still:
- Authenticate when the action is not meant to be public.
- Authorize the specific object the action is about to change. A server action that updates
formData.get("id")is a broken object-level authorization bug if you never load that row as the current user. - Hash passwords. Never store the raw value you just validated.
- Keep secrets out of the closure payload so they are not encrypted and sent to the client.
Next.js documents these points in its server actions security notes. Read that page, then put the request-path controls in the action itself. Middleware is not enough on its own, which is the lesson of the Next.js middleware bypasses.
How should you treat server actions?
Treat every exported server action as a public POST endpoint that happens to have a generated ID. Unique IDs, encrypted closures, and a hidden Request object do not make it private. Validate on the server, call aj.protect(req) with Shield, bot detection, and a fixed window, then run your business logic. That is the same runtime pattern you use on Route Handlers, applied to the API Next.js generated for you.
Frequently asked questions
Are Next.js server actions a security risk?
They are a public HTTP API. Next.js hides the route and assigns a non-deterministic ID, but anyone who can trigger or replay the POST can call the action with any arguments. Treat them like Route Handlers.
Does a unique server action ID make it private?
No. Next.js 15 IDs are security by obscurity. In-use IDs ship in the client bundle and appear in the Next-Action header.
How do you protect a Next.js server action with Arcjet?
Create a client with shield, detectBot({ allow: [] }), and fixedWindow({ window: "10m", max: 5 }). In the action, const req = await request(); then await aj.protect(req). Branch on isDenied() before you persist.
Is client-side Zod validation enough?
No. A proxy can submit the action without the form. Share the schema and run safeParse on the server after protect() allows the request.
Can you rely on middleware instead of protecting the action?
No. Middleware has been bypassable. Put validation, authorization, and aj.protect(req) inside the action.
Application security in your code
Protect your application with Arcjet
Get rate limits, bot detection, and attack blocking in your request handlers.