Skip to content
Back to field notes
FrameworksJune 6, 20266 min read

Next.js server actions: public exposure risks before you ship

Every Next.js server action becomes a callable POST endpoint at deploy, so an action that trusts the UI to gate it runs for any caller. This note shows how to check each one before you share the URL.

Next.jsServer ActionsAuthorization

A server action is a public POST endpoint, not a hidden function

The concrete Next.js server actions security risk is this: when you mark a function with "use server", Next.js compiles it into an HTTP endpoint that the framework can invoke by an action id. It is no longer a private function that only your form can call. After deploy, anyone who can reach your site can POST to that endpoint with their own payload, whether or not the button that triggers it is ever rendered for them.

That matters because the safety of an action does not come from where the button lives. Hiding the button behind an admin layout, a feature flag, or a conditional render only changes what the UI shows. It does not change what the server will execute. If deleteUser or setUserRole runs without checking who is calling, the protection you see in the page is cosmetic.

Separate what is observable from what is only possible here. What is observable from outside: the action endpoint exists and accepts a POST. What is only possible, and what you have to verify in code, is whether that action re-checks the session and the caller's permission before it does its work. The exposure is real; whether it is exploitable depends on the guard inside the function.

  • A server action compiles to a callable endpoint, reachable independent of the component that renders it.
  • A hidden or conditionally rendered trigger is not an access control. The server runs the action regardless.
  • This is an authorization question (is this caller allowed to do this), distinct from authentication (is there a valid session at all). Both have to hold.

Why AI-built Next.js apps skip the guard

AI scaffolding tools are very good at producing the happy path. Ask for "a server action to update the user profile" and you get a clean function that takes form data, writes to the database, and revalidates the page. It works on the first click because you are signed in and on the right screen while you test it. The generated code reads as finished.

The step that does not get generated by default is the boundary inside the action. Auth checks tend to live in the prompt's context, not in the prompt itself, so the model writes the mutation and leaves out the part where the function asks "who is calling, and are they allowed to do this to this record". Three patterns recur in AI-generated Next.js code:

There is also a deploy-speed factor. The app reaches a public URL the moment it builds, and a server action is live from that first deploy. The review loop where a person reads each mutating action and asks what stops an arbitrary caller is the loop that fast iteration skips.

  • The action reads the input and writes, with no call to get the current session inside the function body.
  • Authentication is checked but authorization is not: any logged-in user can act on any record because there is no ownership or role check.
  • The action trusts a hidden field or the absence of a UI affordance, assuming the client would never send a request the UI does not offer.

Check each action before launch

You can verify this on your own app without attacking anything. The goal is to read every mutating action and watch what one actually does over the wire.

Start in the code. Search your project for "use server", and list every action it marks. For each one, read the first lines of the function body. The safe result is that the action calls your session helper (for example auth() or getServerSession()) at the top, returns or throws when there is no session, and then checks that the caller owns or is permitted to touch the specific resource before it mutates. The suspicious result is an action that goes straight to the database write with no session read and no ownership or role check.

Then confirm from the outside on your own deployment. Open DevTools, go to the Network tab, and trigger the action through the UI once. You will see a POST to your route (often the same path as the page) carrying a Next-Action header with the action id. That request is the public interface. The check is conceptual, not an exploit: a mutating action is safe only if replaying that POST while signed out, or while signed in as a different unrelated user, would be rejected by the function itself.

  • Safer result: the action reads the session and rejects missing or wrong callers before any write; an unauthenticated replay would return an auth error, not perform the change.
  • Suspicious result: the action mutates with no session check, or only checks that someone is logged in and never checks that this someone may act on this record.
  • Repeat per action. One protected action says nothing about the next. Pay closest attention to anything that deletes, changes roles or permissions, moves money, or edits records keyed by an id from the input.

Put the auth check inside the action

The fix is to treat every server action as its own protected endpoint, because that is what it is. Do not rely on middleware, layouts, or the rendered UI to gate a mutation.

Inside each mutating action, resolve the session first and reject when it is absent. Then enforce authorization against the specific resource: confirm the caller's role permits the operation, and for record-scoped actions confirm the caller owns the row, by comparing the session user id to the record's owner rather than trusting an id that arrived in the form data. Validate the input shape as well, since the payload is fully attacker-controlled. A shared helper that returns the authenticated user or throws, called at the top of every action, keeps this consistent and hard to forget.

What this fix does not cover: putting an auth check in the action does not validate business logic beyond access (a user allowed to update their own row can still submit values that break an invariant), it does not stop abuse by a legitimate authorized user, and it does not address rate limiting or replay volume. Middleware also does not substitute for the in-action check, because middleware matchers can miss the action route entirely. The companion notes on Next.js middleware auth gaps and auth bypass in AI-generated apps cover the routing side of this.

  • Read the session inside the action body, not only in middleware or a parent layout.
  • Check ownership and role against the session, never against an id the client supplied.
  • Validate and parse the form payload before you act on it; the client can send any shape.

Where VibeCodeGuard fits, and what it does not prove

VibeCodeGuard's Launch Check scans the public surface, and an action that lacks an auth boundary tends to show up there as a callable public endpoint. The scan probes routes from the outside and flags those that respond to a request without an auth gate, which is the same shape an unguarded mutating action presents: a path that accepts a POST and does not redirect or reject an unauthenticated caller. Where it finds one, it reports the exposure with severity and a direction for the fix. It is checking what an outside caller can actually reach, not what your UI displays.

What it does not do is read the inside of your action. It cannot confirm that a given action checks ownership correctly, that a role gate matches your intent, or that the authorization logic you wrote is sound. Those are code and manual-review questions. The scan also will not exercise a real mutation to prove exploitability; it is not a penetration test, a code review, or an exploit verification.

Next step: grep your project for "use server", list every mutating action, and read the first lines of each to confirm it resolves the session and checks the caller's permission before it writes. Then run a focused Launch Check against the public URL before sharing it, so an unguarded action surfaces in your scan and not in someone else's request log.

A clean Launch Check on the public surface does not prove every server action is correctly guarded. The scan sees what is reachable and unprotected from outside; it does not read each action's session and ownership logic. Treat a flagged endpoint as a launch blocker, and treat a clean result as one signal among several, not a guarantee that your authorization is right.

> launch check

Scan the public surface before launch.

Get severity, evidence, and practical fix guidance for the checks VibeCodeGuard can run from the outside.