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.
Why use server is a public endpoint, not a function call
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.
The three checks every action needs at its boundary
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.
- Authentication, who is calling. Re-read the session inside the action, because a page-level or proxy check does not extend to a Server Action defined within that page. If there is no valid session, throw before doing anything else.
- Validation, is the input well-formed. Parse every field with a server-side schema such as Zod. Client validation is a UX convenience, and any attacker can craft a raw request that skips it entirely, so the parse has to happen here.
- Authorization and ownership, may this user act on this specific record. Load the target row and confirm it belongs to the caller. This is the check that stops Insecure Direct Object Reference, where a valid user swaps an ID and edits someone else's data.
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.
Insecure versus hardened, side by side
| 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 |
CSRF, allowed origins, rate limiting, and thin actions
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.
Stay patched: the React2Shell RCE, CVE-2025-66478
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.

