LEAKCADE

Next.js server actions security: every action is a public endpoint

Updated 5 min read

A server action looks like a normal function you call from a button. Under the hood it's a POST endpoint, and anyone who has its ID can call it with any arguments. That's the core of Next.js server actions security: AI coding tools write these actions fast, and it's easy to leave the login check out of the action when the page around the button already has one.

QUICK CHECKLIST

  • Check the session inside every action that reads or writes private data
  • Take the user id from the session, never from arguments
  • Check the record owner before update or delete
  • Validate arguments with zod, write named fields only
  • Return only what the UI needs

Why the page check doesn't protect the action

The Next.js data security guide says an exported server action "is reachable via a direct POST request, not just through your application's UI." It also says a page-level auth check "does not extend to the Server Actions defined within it."

How a server action gets called around the page checkThe admin page redirects logged-out users, but the action ID ships to the browser, an attacker posts to it directly with any userId, and the action runs without a check.PAGE/admin redirectslogged-out usersACTION IDShips in thepage's JavaScriptDIRECT POSTAny userId, nocookie neededdeleteUser()Runs, no authcheck insideHow a server action gets called around the page checkThe admin page redirects logged-out users, but the action ID ships to the browser, an attacker posts to it directly with any userId, and the action runs without a check.PAGE/admin redirects logged-out usersACTION IDShips in the page's JavaScriptDIRECT POSTAny userId, no cookie neededdeleteUser()Runs, no auth check inside

So a redirect on /admin only hides the admin UI. The deleteUser action behind the button still runs for anyone who sends the request, logged in or not. Next.js gives actions encrypted IDs and drops unused ones from the build, but the docs still tell you to verify auth and authorization inside each action.

Three patterns to look for

No login check. The action writes to the database and never asks who is calling:

'use server'
export async function deleteAccount(userId: string) {
  await prisma.user.delete({ where: { id: userId } })
}

Logged in, but not the owner. The action checks for a session, then deletes or updates by an id from the client without checking that the row belongs to that user. Any account can delete anyone's posts. This is the IDOR case the Next.js docs warn about.

The whole client object goes into the database. prisma.user.update({ data: formObject }) writes every field the browser sent, including role or credits if the caller adds them.

What we found in public repos

We searched public GitHub code for 'use server' files in an actions folder that call Prisma's .delete(, and read one actions file from each of 48 repos. Our check flagged 25 of them, with 68 actions that write to the database and show no login check. Those numbers come from the automated check. We then read 10 of the flagged repos by hand, and in all 10 the login check was missing from the action. Roughly 10 of the 25 were tutorials or demos with no login at all.

The rest were real apps. Examples: a deleteAccount(userId) that deletes any user, an admin deleteUser(userId) with no admin check, and an action that sets any user's password from an id and a new password. In a second sample of 15 repos that already use session.user.id, we found public actions that hand out a signup bonus to any user id, and organization updates and deletes that work on any tenant's id.

We picked these files because they matched the search, so this is not a rate across all Next.js apps. We didn't contact the repo owners or test anything live.

Fix Next.js server actions security in six steps

  1. Check the session in every action

    Do it at the top of every action that writes or reads private data, even if the page already checks it.

  2. Take the user id from the session

    Never from the arguments. deleteAccount() should delete the caller's own account.

  3. Check ownership or role

    Before an update or delete, check that the record belongs to the caller. deleteMany({ where: { id: postId, authorId: session.user.id } }) checks ownership in the same query. For admin actions, check the role on the server from your database or session, not from anything the browser sends.

  4. Validate input and pick fields

    Validate arguments with a schema (for example zod) and write only named fields. Never pass the raw form object into data.

  5. Return only what the UI needs

    Next.js serializes the return value and sends it to the browser, where anyone can read it, just like a NEXT_PUBLIC_ variable.

  6. Use a data access layer

    Put auth and database access in one server-only module, so each action stays a thin wrapper. That's the pattern the Next.js guide recommends.

BEFOREAnyone can delete any account
'use server'
export async function deleteAccount(userId: string) {
  await prisma.user.delete({ where: { id: userId } })
}
AFTERCallers delete only their own account
'use server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'

export async function deleteAccount() {
  const session = await auth()
  if (!session?.user) throw new Error('Unauthorized')
  await prisma.user.delete({
    where: { id: session.user.id },
  })
}
The id comes from the session, so the caller can only delete their own account.

With Auth.js, add id to your session type so session.user.id exists.

Fix prompt you can paste

Review every file with 'use server' in this repo, including inline
'use server' functions. For each exported action that reads private data or
writes to the database: add a session check at the top, take the user id
from the session instead of the arguments, check that the record belongs to
the caller before update or delete (or check an admin role for admin
actions), validate arguments with zod and write only named fields, and
return only what the UI needs. Leave sign-up, login and password-reset
actions public but rate limited. List every action you changed and write a
test where a logged-out user and a second user call each action directly.

How Leakcade checks this

Our server-action check reads your 'use server' files as text. It flags actions that write to the database with no visible login check, updates and deletes by a client id with no owner or admin check, and whole client objects written to the database. Sign-up, login and reset actions are left alone. Auth helpers imported from other files can hide a check from us, so the report marks these findings as heuristic and tells you what to verify.

Using next-safe-action or a similar wrapper? We skip those actions for now.

Further reading

  1. Next.js docs: Data security (Server Actions)
  2. Next.js docs: Server Actions and Mutations (Security)
  3. Next.js docs: Authentication
  4. OWASP: Insecure Direct Object Reference Prevention
  5. OWASP: Next.js Security Cheat Sheet

FAQ

Are Next.js server actions secure by default?

Partly. Next.js compares the request's Origin with the host to block cross-site requests, caps the body at 1 MB and encrypts action IDs. None of that checks who the user is. Auth and ownership checks are up to you.

Do I need an auth check in every server action?

In every action that reads private data or changes anything. The Next.js docs say a page-level check doesn't extend to the actions on that page.

Is middleware enough to protect server actions?

No. The Next.js docs recommend middleware (now called Proxy) only for quick, optimistic checks and a secure check close to the data, inside the action or a data access layer.

Should server actions be rate limited?

Expensive or destructive ones, yes: anything that sends email, calls a paid API or deletes data. The same tools as for login rate limiting work, keyed on the user id from the session.

Want a second pair of eyes?

We review your whole repo and write a fix prompt for every finding. $19, report within an hour.

Check my repo

Leakcade is an automated check. It can miss vulnerabilities and is not a substitute for a professional security audit or pentest. Provided as is. You stay responsible for your code. Where the law allows, our liability is limited to what you paid ($19). Terms

Patch Notes by Leakcade

A short weekly email: what changed in AI coding tools, one real security mistake, one prompt you can reuse.