Skip to content
All guides

Are Next.js Server Actions public? How to secure 'use server' functions

By Obscura Security · Updated · Next.js, React, Cursor, v0, Vercel

Short answer

Yes. A Next.js Server Action is an endpoint anyone can send a POST request to, with any arguments, whether or not your interface shows the button. Check who is calling and whether they are allowed inside every action, and validate every argument. The same goes for Route Handlers.

A Server Action looks like a function call. You write an async function, mark it with "use server", and call it from a button. It feels private, as if it belonged to your server. It is a public web endpoint.

Why this happens

When you mark a function with "use server", Next.js creates an HTTP endpoint for it. Your client code calls that endpoint with a POST request. The request carries an identifier for the action (in a Next-Action header) and the arguments. Nothing in that request proves the caller clicked your button.

Next.js gives each action an ID and leaves out actions your app never uses. That is housekeeping, not access control. The ID of any action your app uses is in the JavaScript sent to every visitor, and anyone can replay a request they have captured in the Network tab, changing the arguments as they like.

Two things make this easy to miss:

  • Every export of a "use server" file is an action. If a file starts with "use server" and exports deleteUser, updateRole and a helper, all three can be called.
  • AI tools put the check in the component. The usual output is {isAdmin && <DeleteButton />}, with an action behind it that does the deletion for whoever asks. This is the same gap as an admin page that is only hidden in the UI.

Checks that live in a layout, or only in middleware for certain paths, are also not a safe substitute. Layouts do not always re-run when a user navigates inside your app, and an action runs as its own request. Put the check in the action.

How to check your own app

Do this against your own app with two test accounts: a normal user and an admin.

  1. Search your code for "use server". List every action, and for each one, ask: does the function itself check who the user is, and whether they may do this to this record?
  2. Pick an action that only an admin should be able to run. Sign in as the admin, open the browser’s Network tab, and use the feature. Find the POST request with a Next-Action header.
  3. Right-click it and copy it as curl. Replace the cookie with the one from your normal test user’s session, and send it. If the action works, or only fails on a missing record, the check is missing.
  4. Change an argument, such as a user ID or amount, and see whether the action accepts a value the interface would never send.
  5. Search app/api for route.ts files and do the same check on each.

The fix

1. Authenticate and authorise inside every action

'use server'

import { z } from 'zod'
import { getCurrentUser } from '@/lib/auth'
import { prisma } from '@/lib/prisma'

const deleteUserSchema = z.object({ userId: z.string().uuid() })

export async function deleteUser(input: unknown) {
  // 1. Who is calling? Work it out on the server.
  const user = await getCurrentUser()
  if (!user) throw new Error('Unauthorised')

  // 2. Are they allowed? Read the role from your database or session, never from the arguments.
  const dbUser = await prisma.user.findUnique({
    where: { id: user.id },
    select: { role: true },
  })
  if (dbUser?.role !== 'admin') throw new Error('Forbidden')

  // 3. Is the input what you expect?
  const { userId } = deleteUserSchema.parse(input)

  await prisma.user.delete({ where: { id: userId } })
}

Typing the argument as unknown and parsing it makes sure you do not trust a TypeScript type. Types disappear at runtime, and the caller is not bound by them.

2. Check ownership, not only login

Being signed in is not enough for actions on a record. A user who can call updateOrder(orderId, ...) should only reach their own orders:

const order = await prisma.order.findFirst({
  where: { id: orderId, userId: user.id },
})
if (!order) throw new Error('Not found')

Without the userId condition, any signed-in user can change anyone’s order by changing the ID. See users can edit fields they shouldn’t for the matching problem with update data.

3. Put the checks in a helper

Put the repeated checks in one function, such as requireUser() and requireAdmin(), and call it on the first line of every action and Route Handler. Then a missing check stands out in review.

4. Don’t pass trusted data through arguments

Anything the client sends is chosen by the client: a price, a user ID, a role, a flag like isAdmin. Look these up on the server. Closure-captured values are encrypted by Next.js, but arguments are not, so do not build security on “the client would never send that”.

5. Treat Route Handlers the same way

A route.ts file with export async function POST is a public URL. It needs the same three steps: authenticate, authorise, validate.

6. Test again

Repeat the replayed request from step 3 above, with the normal user’s cookie. It should be refused.

Mistakes to avoid

  • Checking the role from the request. A role argument, header or hidden form field can be set by the caller.
  • Relying on an action being obscure. Unused actions may not be exposed, but code changes, and any action your app uses should be treated as public.
  • Returning the whole database row. Select only the fields the caller needs.
  • Sending raw error messages to the browser. Log the real error on the server, and return a short message to the user.
  • Skipping checks for “internal” actions. If the file has "use server", every export in it is callable.

Common questions

Are Next.js Server Actions secure by default?
They have some built-in protection against cross-site request forgery, because actions only run on POST requests and Next.js compares the Origin header with the Host. They do not check who the user is or whether they are allowed to do the thing. That part is your job, in every action.
If I only render the button for admins, is the action protected?
No. The action is reachable by a request whether or not any page renders the button. Hiding the button changes what honest users see and nothing else.
What about variables my action uses from the surrounding component?
Next.js encrypts values an action captures from its closure, so they are not readable in the client. Arguments passed when the action is called are different: they come from the browser and can be anything. Treat them as untrusted input.
Do I need to protect Route Handlers the same way?
Yes. Route Handlers (app/api/.../route.ts) are public URLs. Each one needs its own authentication and authorisation check, plus input validation.

Written by Obscura Security, an Australian practice that checks web apps for problems like this one. If you want a second pair of eyes on yours, email [email protected].