Securing Next.js Server Actions: Auth, IDOR & Zod Validation

Photo by FlyD on Unsplash
No. Every exported use server function compiles into a public POST endpoint reachable by a direct request, not just through your UI. Next.js adds encrypted action IDs and Origin/Host CSRF checks, but these are not authorization. You must verify authentication and authorization inside each action yourself.
No. Types and client-side Zod schemas only run in the browser, which an attacker fully controls. They can read the action ID and replay the POST with any payload. All validation that matters for security must run server-side inside the action, typically with a Zod safeParse at the boundary.
Authentication alone is not enough, because any logged-in user could pass someone else's record ID. After authenticating, load the target row from the database and confirm it belongs to the caller (for example, checking that post.authorId equals the session user's ID) before mutating it. Throw Forbidden otherwise.
No. A page redirect or proxy check only controls which UI renders. A Server Action is a separate entry point and must re-verify the caller on its own. The Next.js docs call the in-action authentication check critical for exactly this reason.
CVE-2025-66478 (React2Shell) is a CVSS 10.0 remote code execution flaw disclosed on December 3, 2025, stemming from unsafe deserialization in the React Server Components protocol. It affects Next.js 15.x, 16.x, and canary builds from 14.3.0-canary.77 using the App Router. There is no workaround; upgrade to a patched release such as 15.5.7 or 16.0.7 and rotate secrets.

Photo by FlyD on Unsplash
Key Takeaway
Every Next.js Server Action marked use server compiles to a public POST endpoint that anyone can call directly, so TypeScript types and client-side validation protect nothing. Each action needs its own authentication, resource-ownership authorization to stop IDOR, server-side Zod validation at the boundary, and rate limiting on expensive operations.
The first time I shipped a Server Action, it felt like calling a local function. I imported it, passed it to a form action, and the mutation just worked. That ergonomics is exactly the trap. A Server Action is not a private function that only your UI can reach. It compiles into a real HTTP endpoint, and the Next.js docs are blunt about it: once an action is created and exported, it is reachable via a direct POST request, not just through your application's UI.
That single fact reframes the whole security model. An attacker never touches your React components. They open the network tab, read the action ID, and replay the POST with any payload they like. Your TypeScript signature, your disabled submit button, your client-side Zod schema, your conditional rendering that hides the button from non-admins, all of it runs in the browser, and the browser is the one place you do not control. This post is how I harden every action I write.
Next.js does add real protections. It generates encrypted, non-deterministic action IDs so clients cannot guess them, it recalculates those IDs between builds, and dead-code elimination strips any action you never actually reference so it never becomes a callable endpoint. Server Actions also only accept POST and compare the Origin header against the Host header, which blocks most CSRF in modern browsers, especially with SameSite cookies as the default.
But none of that is authorization. The docs say it plainly: this reduces risk where an auth layer is missing, yet you should still treat Server Actions as reachable via direct POST requests and verify authentication and authorization inside each one. Encrypted IDs stop guessing, not a logged-in user replaying a request for a resource that is not theirs. The security boundary is the action body, and nothing before it can be trusted.
A mental model that stuck with me: treat every use server function with the same suspicion you would give a public REST route. If you would not expose it as an unauthenticated API endpoint, do not ship it as a Server Action without the same checks. The convenient function-call syntax is a UI feature, not a security feature.
Over time I settled on a fixed order for the top of every mutating action. Skipping any one of them leaves a real hole, and the order matters because each step depends on the one before it.
Here is the pattern I reach for, adapted directly from the Next.js data-security guide. The three checks run in order before a single byte is written, and the database access is delegated to helpers so the action stays thin and auditable:
'use server'
import { z } from 'zod'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
const UpdatePostSchema = z.object({
postId: z.string().uuid(),
title: z.string().min(1).max(200),
})
export async function updatePost(formData: FormData) {
// 1. Authentication — who is actually calling this endpoint?
const session = await auth()
if (!session?.user) {
throw new Error('Unauthorized')
}
// 2. Server-side validation at the boundary (never trust the client)
const parsed = UpdatePostSchema.safeParse({
postId: formData.get('postId'),
title: formData.get('title'),
})
if (!parsed.success) {
throw new Error('Invalid input')
}
const { postId, title } = parsed.data
// 3. Authorization / ownership — does THIS user own THIS row? (stops IDOR)
const post = await db.post.findUnique({ where: { id: postId } })
if (!post || post.authorId !== session.user.id) {
throw new Error('Forbidden')
}
await db.post.update({ where: { id: postId }, data: { title } })
revalidatePath('/posts')
return { success: true }
}Notice the ownership check on line with post.authorId. Authentication alone would happily let any logged-in user delete or edit any post by ID. That gap is the single most common Server Action vulnerability I see in reviews, and it is invisible in the type system because the ID is just a string. The database lookup plus the equality check is what closes it.
Do not rely on a redirect at the top of the page to protect the action inside it. The page-level auth check controls which UI renders. The Server Action is a completely separate entry point and must re-verify the caller on its own. The docs call the in-action auth check critical for exactly this reason, and the same applies to proxy or middleware checks, which are optimistic, not a data-source guarantee.
| Concern | Insecure action | Hardened action |
|---|---|---|
| Authentication | Relies on the page redirect or hidden button | Re-reads the session inside the action body |
| Input handling | Trusts formData and typed arguments as-is | Parses every field with Zod safeParse first |
| Authorization | Acts on any ID the caller supplies | Loads the row and checks ownership, blocking IDOR |
| Return value | Returns the raw database record | Returns only the fields the UI needs |
| Abuse control | No throttling on writes, emails, or logins | Rate-limits expensive operations per user |
Beyond the three core checks, a few extras matter. Server Actions already compare Origin to Host to block CSRF, but if you run behind a reverse proxy where the API host differs from the public domain, configure serverActions.allowedOrigins so legitimate cross-host requests are not silently aborted. For expensive operations, sending email, writing to a database, verifying credentials, add rate limiting per user or IP, since a public endpoint with no throttle is an open invitation to abuse and credential stuffing.
I also keep actions thin by pushing authentication, authorization, and database logic into a server-only Data Access Layer, then having the use server function delegate to it. This centralizes the checks so they are consistent and auditable, and it keeps secrets and the process.env access in one place. Two more habits: return only what the UI needs rather than raw rows, and never capture sensitive data in an action closure, because closed-over variables travel to the client and back, protected only by encryption you should not lean on alone.
Application-level checks do not save you from a framework-level flaw, and 2025 delivered a severe one. On December 3, 2025, Next.js disclosed CVE-2025-66478, the downstream App Router impact of an upstream React vulnerability, CVE-2025-55182, nicknamed React2Shell. It is rated CVSS 10.0. The React Server Components protocol deserialized untrusted input in a way that, under specific conditions, let a crafted request trigger unintended server execution paths, resulting in remote code execution, with no authentication required.
The affected versions are Next.js 15.x, 16.x, and canary releases from 14.3.0-canary.77 onward, when using React Server Components with the App Router. Next.js 13.x, stable 14.x, Pages Router apps, and the Edge Runtime are not affected. Fixed releases include 15.0.5, 15.1.9, 15.2.6, 15.3.6, 15.4.8, 15.5.7, and 16.0.7. There is no workaround; upgrading is required, and Vercel recommends rotating application secrets after patching and redeploying.
The lesson pairs with everything above. Your per-action auth and validation are the controls you own, and you must get them right, because they are the difference between a locked-down app and an open API. But you also owe your users a boring, disciplined upgrade habit, so that when a CVSS 10.0 lands on a Friday, you are already on a patched line or one command away from it. Security is the boundary plus the patch, not one or the other.