Security

Secure Server Actions in Next.js 16

14 min readMussawar Hayat

Every Server Action is a public POST endpoint. Learn the production pattern: validate inputs, authenticate from the session, authorize ownership, keep a thin action layer on a server-only Data Access Layer, and constrain return values.

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:

  1. Validate inputs with Zod or Valibot. FormData and headers are untrusted.
  2. Authenticate from the session (cookies/headers). Never accept identity from the client.
  3. 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'.

data/posts.ts 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 } })
}
app/actions/posts.ts use server
'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 vs safe ownership
// 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.

Constrained DTO return
// 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 data
  • revalidateTag — stale-while-revalidate
  • revalidatePath — by URL
  • refresh — 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.

Ready to start your project?

Let us discuss how we can transform your digital presence with cutting-edge solutions.