Server Actions Are Public Endpoints: How We Harden Next.js Mutations
Table of Contents
About the Author

Ahmed Mahmoud
Author & Developer
Software engineer passionate about web development and user experience design.
Founder of Devya · eng-ahmed.com ↗Every Server Action in Next.js compiles to a public HTTP endpoint. The "use server" directive marks a network boundary, not a private function: any client can invoke an action with a crafted POST request, whether or not the interface ever renders the button that triggers it. At Devya we treat every action the way we treat a route handler — validate input at runtime, authorize inside the action, and audit what closures capture. These are the practices we apply across our Next.js projects.
Key takeaways
• A Server Action compiles to a public POST endpoint identified by a build-generated action ID. Hiding or disabling the button that calls it is not access control.
• TypeScript argument types are erased at build time. We validate every action's input at runtime with a schema library such as Zod and type the arguments as unknown so the compiler forces a parse.
• Authentication and authorization belong inside each action or a shared wrapper, not only in middleware. CVE-2025-29927 (March 2025) showed that middleware-only auth can be bypassed on self-hosted Next.js.
• Variables captured by an inline action's closure are encrypted, sent to the client, and sent back on invocation. Set NEXT_SERVER_ACTIONS_ENCRYPTION_KEY when running multiple server instances.
• serverActions.allowedOrigins and serverActions.bodySizeLimit in next.config harden the transport. Next.js already rejects cross-origin action POSTs by comparing the Origin and Host headers.
What does a Server Action actually compile to?
A Server Action is a public HTTP endpoint. When a function is marked with "use server", the Next.js build assigns it an action ID and wires it into the framework's POST handler. Invoking the action sends a POST request to the current page URL with a Next-Action header carrying that ID and the serialized arguments in the body. There is no separate URL to firewall — the action rides the same route the page renders on.
Two consequences follow. First, every exported function in a "use server" file is network-reachable; recent Next.js versions tree-shake actions that are never imported, but the safe mental model is that an exported action is a live endpoint. Second, the caller fully controls the payload. The framework deserializes whatever arrives — nothing guarantees the arguments match the TypeScript signature.
Why isn't hiding the button enough?
Conditional rendering is not authorization. If an admin panel renders a delete button only for admins, that check lives in the UI — the action ID still ships in the client bundle, and any user can replay the POST with their own payload. This is the same lesson as classic REST APIs: nobody would protect DELETE /api/users/:id by hiding a link. A Server Action deserves the same suspicion as an exposed route handler.
How do we validate Server Action input?
We assume every argument is attacker-controlled, because it is. TypeScript types on the signature validate nothing at runtime. Our pattern: type the input as unknown and parse it with Zod at the top of the action — for example, a schema that requires userId to be a UUID and role to be one of viewer or editor, then a call to UpdateRole.parse(input) before any data access. The compiler then refuses to let unvalidated data through.
For form actions the argument is a FormData object — we convert it with Object.fromEntries and run it through the same schema. Note what the schema excludes: if the admin role is not in the enum, mass assignment through an extra field dies at the parse step instead of reaching the database.
Where do auth checks belong?
Inside the action, next to the data it touches. Middleware still runs on action POSTs, but middleware answers whether a request may reach a route — not whether a user may mutate a row. CVE-2025-29927, the March 2025 header-spoofing bypass on self-hosted Next.js, made the sharper point: when middleware is the only auth layer, one framework bug removes all of it.
We wrap actions in a shared authedAction helper that loads the session, rejects unauthenticated calls, and passes the user into the action body. Inside the action, the database query filters by ownership — for example, finding a project by both its ID and ownerId before deleting it. The wrapper proves who is calling; the ownership filter proves they own what they are touching. Skipping that second step is the textbook IDOR vulnerability.
What do inline closures capture, and who can read them?
An action defined inline inside a Server Component closes over variables from the render scope. Next.js encrypts those captured values with a key generated at build time, embeds the ciphertext in the page payload, and decrypts it when the action is invoked. Two operational notes from our deployments: with rolling deploys or multiple instances, each build generates a different key, so invocations can fail to decrypt across versions — set the NEXT_SERVER_ACTIONS_ENCRYPTION_KEY environment variable to keep it consistent. And encryption is not a license to capture secrets: the ciphertext lives in the client, so we pass IDs and refetch sensitive data server-side instead.
Which config options harden Server Actions?
• Cross-origin invocation: Next.js compares the Origin and Host headers and rejects mismatches; behind proxies or multiple domains, configure serverActions.allowedOrigins explicitly.
• Oversized payloads: serverActions.bodySizeLimit caps the request body at 1 MB by default — raise it per need, not globally.
• Secrets leaking to the client: React's experimental taint APIs (experimental_taintObjectReference, experimental_taintUniqueValue) throw when tainted objects are passed into client components.
• Abuse and flooding: we rate-limit inside the shared action wrapper, keyed by user ID or IP.
• Stale action IDs: IDs rotate on every build, so a client on an old page version can fail after a deploy — handle action failures with an error boundary and a refresh hint.
FAQ
Q: Do Server Actions have built-in CSRF protection?
A: Partially. Actions only accept POST requests, and Next.js rejects requests whose Origin header does not match the Host. Behind a proxy or across multiple domains, serverActions.allowedOrigins must list the allowed origins explicitly.
Q: Can someone invoke a Server Action from a page they cannot see?
A: Yes. Action endpoints do not know what the UI rendered. Any client that knows an action ID can send the POST, so every action must authenticate and authorize on its own.
Q: Does TypeScript validate Server Action arguments at runtime?
A: No. Types are erased at compilation. A caller can send any serializable payload, which is why runtime schema validation with Zod, Valibot, or ArkType is non-negotiable.
Q: Does middleware run for Server Action requests?
A: Yes, action POSTs pass through middleware like any other request. But middleware-level auth alone is route-level, was bypassable via CVE-2025-29927, and is blind to row-level ownership. Keep it as one layer, not the only layer.
Q: Is it safe to keep helper functions in a file marked "use server"?
A: No. Every export of a "use server" file becomes a reachable endpoint. Keep helpers in a separate unmarked file and export only real actions from action files.
Further Reading
Frontend Engineering
Modern CSS Replaced Our Layout JavaScript: Container Queries, :has(), and Subgrid in Production
Container queries, :has(), subgrid, and cascade layers let our team delete most of the layout JavaScript we used to ship — ResizeObserver wrappers, parent-class toggles, and height-measuring passes. Here is what each feature replaces, and the two we still treat as progressive enhancement.
Frontend Engineering
AbortController Beyond fetch: Cancellation Patterns We Use in Every React App
Uncancelled fetches do not fail loudly — they resolve late and overwrite fresh state. The AbortController patterns we use in every React app we ship: useEffect cleanup, AbortSignal.timeout(), AbortSignal.any(), and one-signal listener cleanup.