Every Server Action Is a Public Endpoint
In Next.js 16, a Server Action is a React Server Function invoked through a form action, button formAction, or client transition. The compiler replaces the function body in client bundles with an encrypted action ID and a dispatcher that POSTs back to the server. The implementation never leaves the server, but the route is reachable by anyone who can craft the same POST.
Framework-level protections (Origin/Host CSRF check, body size limit, encrypted action IDs, closure encryption) reduce risk. They do not replace application-level authentication, authorization, and input validation. Render-time gating is not a security boundary.
What You Will Learn
- Why Server Actions must be treated as untrusted public endpoints
- The three mandatory checks: validate, authenticate, authorize
- Thin Server Action layer on a server-only Data Access Layer (DAL)
- Ownership checks, constrained return values, and revalidation
- Production checklist and common mistakes
1. What Next.js Already Protects
Official documentation lists four framework guarantees: CSRF check (Origin vs Host), body size limit (default 1MB), encrypted action IDs with dead-code elimination, and closure variable encryption. These are baseline defenses. They do not check who the caller is or whether the caller owns the resource.
2. The Three Checks Inside Every Action
Treat every exported Server Action as an untrusted entry point. Inside the function, in this order:
- Validate inputs with Zod or Valibot. FormData and headers are untrusted.
- Authenticate from the session (cookies/headers). Never accept identity from the client.
- Authorize ownership or role against the specific resource.
Only after those three steps perform the mutation and revalidate.
3. Thin Actions + Server-Only Data Access Layer
Keep use server files thin. Put auth and database logic in a module marked with import 'server-only'.
import 'server-only'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { z } from 'zod'
const createPostSchema = z.object({
title: z.string().min(1).max(200),
body: z.string().min(1).max(50000),
})
export async function createPost(input: unknown) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const data = createPostSchema.parse(input)
return prisma.post.create({
data: {
title: data.title,
body: data.body,
authorId: session.user.id,
},
select: { id: true, title: true },
})
}
export async function deletePost(postId: string) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const post = await prisma.post.findFirst({
where: { id: postId, authorId: session.user.id },
select: { id: true },
})
if (!post) throw new Error('Forbidden')
await prisma.post.delete({ where: { id: post.id } })
}
'use server'
import { createPost, deletePost } from '@/data/posts'
import { revalidatePath } from 'next/cache'
export async function createPostAction(formData: FormData) {
const post = await createPost({
title: formData.get('title'),
body: formData.get('body'),
})
revalidatePath('/posts')
return { id: post.id }
}
export async function deletePostAction(postId: string) {
await deletePost(postId)
revalidatePath('/posts')
}
4. Ownership Checks, Not Client-Supplied Rows
Never accept a full resource from the client and write it back. Accept an ID plus the change, then re-read under the session ownership constraint.
// Unsafe — any caller can complete any item by ID
export async function completeItemUnsafe(item: { id: string }) {
await prisma.item.update({
where: { id: item.id },
data: { completed: true },
})
}
// Safe — auth + ownership query before mutation
export async function completeItem(itemId: string) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const item = await prisma.item.findFirst({
where: { id: itemId, ownerId: session.user.id },
})
if (!item) throw new Error('Forbidden')
await prisma.item.update({
where: { id: item.id },
data: { completed: true },
})
}
5. Constrain Return Values
Action returns are serialized to the client. Return DTOs with explicit select, never raw ORM records or sensitive columns.
// Prefer explicit select — never return passwordHash, tokens, etc.
return prisma.user.findUnique({
where: { id: session.user.id },
select: {
id: true,
name: true,
email: true,
},
})
6. Revalidation After Mutation
updateTag— immediate; action response waits for fresh datarevalidateTag— stale-while-revalidaterevalidatePath— by URLrefresh— refetch current route RSC payload
Call revalidation before redirect. Redirect throws; code after it does not run.
7. Production Checklist
- Validate input with a schema before business logic
- Authenticate from session, never from client identity
- Authorize ownership or role on the specific resource
- Keep DB and auth in server-only modules; actions stay thin
- Return DTOs only
- Set serverActions.allowedOrigins behind proxies/CDNs
- Do not parallelize Server Actions from the client with Promise.all
8. Common Mistakes
- Assuming a form only rendered for logged-in users is enough
- Accepting a full object from the client without ownership query
- Importing Prisma into Client Components
- Returning entire records or internal errors to the client
- Skipping schema validation because the form constrains fields
Summary
Server Actions are a convenient mutation surface, not a trust boundary. Production safety comes from validate, authenticate, authorize on every path, a server-only DAL, constrained return values, and deliberate revalidation.
Key Takeaway
Treat every Server Action as a public POST endpoint. Thin actions, a server-only DAL, and ownership-scoped queries are the production baseline.
Need a security pass on your Server Actions?
I review Next.js App Router codebases for auth gaps, missing ownership checks, and unsafe return values. Get in touch for a focused audit.
