A Server Action feels like a function you call, so it is easy to treat it like private internal code. It is not. Next.js Server Actions security starts with one fact that changes how you write every one of them: an action marked "use server" compiles down to a public HTTP endpoint. The framework wires up a POST route for it and ships an identifier to the browser so the client can invoke it. Anyone who can reach your site can reach that route directly, with a request they wrote by hand, without ever loading your interface.
Why Next.js Server Actions security is really API security
When you write an async function in the App Router, mark it "use server", and import it into a client component, Next.js does not send that function to the browser. It keeps the code on the server and replaces the import with a reference: an action id plus a fetch that posts to your app. Clicking the button in your UI sends that POST. So does curl. So does a script that read the action id out of your bundle. The endpoint does not check where the call came from, and it cannot, because a request is just a request.
This means every Server Action is an API route wearing the clothes of a function. It needs the same three things every API route needs, on every call: proof of who is asking, a check that this caller is allowed to do this thing, and validation of the arguments before they touch your database. Skip any of them and the action is open to whoever finds it.
A Server Action is not protected by the component that imports it. It is a public POST endpoint, and the only guard that counts is the code inside the function.
The five ways quickly built apps get this wrong
These are the shapes that keep turning up in AI generated Next.js code and in apps assembled fast. Each one comes from trusting the interface instead of the server.
1. Assuming an admin only import is an admin only action
The action lives in an admin dashboard. It is imported by a component that only renders for staff. The reasoning goes: users never see this, so users cannot call it. But the import graph is a client side detail. The endpoint is live for every visitor the moment the app boots. Reachability has nothing to do with which component references the function.
2. No session check inside the action
The action reads and writes data but never asks who is calling. The page around it was behind a login, so the action inherited a feeling of safety it never actually had. A direct POST arrives with no session and the action runs anyway. This is a Server Action with no authentication, the same class of bug as broken function level authorization: a privileged operation that forgot to check the caller’s privileges.
3. No ownership check, so an id mutates someone else’s data
The action takes an id argument and updates that record. It checks that you are logged in, then trusts the id you sent. Pass another user’s record id and you edit their data. That is an insecure direct object reference reached through a Server Action. Being signed in is not the same as being allowed to touch this specific row.
4. Trusting arguments without validation
Server Action arguments arrive as a serialized payload from the client. A hand crafted request can send a number where you expected a small positive integer, a string where you expected an enum, an object with extra fields, or a role of admin you never meant to accept. If the action passes those straight into a query or an update, the shape of your data is now decided by the attacker.
5. A privileged mutation guarded only by a hidden button
The dangerous action, delete an account, refund an order, grant a role, is protected by the fact that its button only appears for the right person. Hiding the button hides it from honest users looking at the screen. It does nothing to the endpoint. The guard has to live in the function, not in whether the UI chose to render a control.
An insecure Server Action, then a fixed one
Take an invented app, Acme Boards, where users own boards and can rename them. Here is the version that looks fine in a demo and is open in production.
// app/actions/rename-board.ts
"use server";
import { db } from "@/lib/db";
// Insecure: no auth, no ownership, no validation.
export async function renameBoard(boardId: string, name: string) {
await db.board.update({
where: { id: boardId },
data: { name },
});
}
Nothing here asks who is calling, whether they own the board, or whether name is sane. A single POST with any boardId renames any board in the system. Now the version that treats the action as the public endpoint it is.
// app/actions/rename-board.ts
"use server";
import { z } from "zod";
import { db } from "@/lib/db";
import { getSession } from "@/lib/auth";
const RenameInput = z.object({
boardId: z.string().uuid(),
name: z.string().trim().min(1).max(80),
});
export async function renameBoard(raw: unknown) {
// 1. Authenticate: who is calling?
const session = await getSession();
if (!session) {
throw new Error("Not authenticated");
}
// 2. Validate: are the arguments the shape we expect?
const { boardId, name } = RenameInput.parse(raw);
// 3. Authorize ownership: does this caller own this board?
const board = await db.board.findUnique({
where: { id: boardId },
select: { ownerId: true },
});
if (!board || board.ownerId !== session.userId) {
throw new Error("Not allowed");
}
// 4. Only now perform the mutation.
await db.board.update({
where: { id: boardId },
data: { name },
});
}
The order is the point. Get the session first. Validate the input against a schema so unexpected shapes are rejected before they matter. Look up the record and confirm the caller owns it, comparing against an identity the server verified, not an id the request supplied. Then, and only then, write. Do this in every action, because each one is its own front door.
How to test it from outside
You do not need your own UI to call a Server Action, which is exactly why you should try calling it without one. Only ever do this against an app you own or have written permission to test. Open the network tab, trigger the action once through the interface, and read the request it sends. You will see a POST to your own route carrying the action id in a header and your arguments in the body. Copy that request. Then change it.
- Send it with no session. Drop the auth cookie and replay. If the mutation still happens, there is no authentication check inside the action.
- Send another user’s id. Sign in as one test account, take a valid request, and swap the target id for a record owned by a second account you created. If it succeeds, the ownership check is missing.
- Send junk arguments. Post a negative number, a giant string, an extra field, or a wrong type. If the action does not reject it, there is no validation.
Every failure here maps to one of the five shapes above, and every one is fixed in the same place: the server, on every call. This is the same lesson that runs through the rest of our guide to vibe coded app security, and it sits squarely in access control.
These are not bugs a signature scanner catches, because the request is well formed and the response is a clean success. Finding them means understanding what an action is meant to allow and then checking whether the endpoint agrees. In our own early testing, a frontier model drove the full methodology on its own and identified and verified real access control and injection issues in test applications it had not seen before. That is the kind of assumption UnboundCompute is built to test, and you can read more on our about page.
Frequently asked questions
Are Next.js Server Actions private server code?
No. A function marked "use server" compiles to a public HTTP endpoint. Next.js ships an action id to the browser and wires up a POST route, so anyone who can reach your site can invoke the action directly with a crafted request, not just your interface.
Does importing an action only in an admin component protect it?
No. The import graph is a client side detail and has nothing to do with reachability. The endpoint is live for every visitor once the app boots. The only guard that counts is the code inside the action, so every action needs its own checks.
What checks should a Server Action run on every call?
Three, in order. First authenticate the caller and confirm there is a valid session. Second validate the arguments against a schema, for example with zod, so unexpected shapes are rejected. Third confirm the caller is allowed to touch the specific record before you mutate anything.
How does an IDOR happen through a Server Action?
The action checks that you are logged in but then trusts an id you sent and updates that record. Pass another user’s id and you edit their data. The fix is an ownership check: look up the record and compare its owner against the verified session identity, not the id in the request.
How do I test a Server Action from outside my UI?
On an app you own, open the network tab, trigger the action once through the interface, and read the POST it sends. Copy that request, then replay it with no session, with another test account’s id, and with junk arguments. Any mutation that still succeeds points to a missing auth, ownership, or validation check.
Why do scanners miss broken Server Action authorization?
Because the request is well formed and the response is a clean success. Whether the caller is allowed to rename this board or delete that account is a fact about your application, not a known bad pattern. It takes a tester that learns the app’s rules and checks whether the endpoint enforces them.
Put an autonomous researcher on your own systems
UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.
