Skip to content

Security and Patterns

Every Server Action is a public HTTP endpoint, so you must validate the input and authorize the caller inside the action itself — never trust that it was only reached from your own UI.

Anyone who discovers the action ID can call it with any payload. So the action must re-check who the user is and re-parse the input on every call. Authorize first, validate second, then mutate — the UI that rendered the form is not a security boundary.

// app/actions.ts — authorize, then validate, then mutate
'use server'
import { auth } from '@/lib/auth'
import { z } from 'zod'
const schema = z.object({ id: z.string().uuid(), title: z.string().min(1) })
export async function updatePost(formData: FormData) {
const session = await auth()
if (!session?.user) throw new Error('Unauthorized')
const parsed = schema.safeParse({
id: formData.get('id'),
title: formData.get('title'),
})
if (!parsed.success) return { error: 'Invalid input' }
await db.post.update({
where: { id: parsed.data.id, authorId: session.user.id },
data: { title: parsed.data.title },
})
}

The built-in protections are not authorization

Section titled “The built-in protections are not authorization”

Next.js encrypts variables you close over in an action, generates unguessable action IDs, and dead-code-eliminates any action you never use so it never becomes a public endpoint. These reduce the attack surface, but none of them check whether THIS user may perform THIS action. Authorization and validation remain your job.

// app/actions.ts — unused actions are stripped at build; used ones get an ID
'use server'
// Used in the app: Next.js creates an encrypted, unguessable ID for it.
export async function updateUser(formData: FormData) {}
// Never imported anywhere: dead-code-eliminated, no public endpoint exists.
export async function deleteUser(formData: FormData) {}

A reusable action wrapper with typed results

Section titled “A reusable action wrapper with typed results”

To avoid repeating the auth and validation boilerplate, wrap actions in a helper that authorizes, parses with a schema, and returns a typed result. Every action then returns the same success-or-error shape, which pairs cleanly with useActionState.

// app/lib/action.ts — one wrapper: authorize, validate, typed result
import { auth } from '@/lib/auth'
import { z } from 'zod'
type Result<T> = { ok: true; data: T } | { ok: false; error: string }
export function authedAction<S extends z.ZodType, T>(
schema: S,
run: (input: z.infer<S>, userId: string) => Promise<T>,
) {
return async (formData: FormData): Promise<Result<T>> => {
const session = await auth()
if (!session?.user) return { ok: false, error: 'Unauthorized' }
const parsed = schema.safeParse(Object.fromEntries(formData))
if (!parsed.success) return { ok: false, error: 'Invalid input' }
const data = await run(parsed.data, session.user.id)
return { ok: true, data }
}
}

Do not scatter raw database queries across actions. Route every read and write through a data-access layer whose functions each enforce their own ownership checks. Actions call that layer, so authorization lives next to the data and is hard to forget.

// app/data/posts.ts — the only place that touches the posts table
import 'server-only'
export async function updatePostForUser(id: string, userId: string, title: string) {
return db.post.update({
where: { id, authorId: userId },
data: { title },
})
}
graph TD
  A["Public request hits action ID"] --> B{"Authorized user?"}
  B -->|"No"| C["Reject: Unauthorized"]
  B -->|"Yes"| D{"Input valid by schema?"}
  D -->|"No"| E["Reject: Invalid input"]
  D -->|"Yes"| F["Data-access layer with ownership check"]
  F --> G["Mutate the database"]
Every request re-checks auth and input
Why must you authorize and validate inside every Server Action?
What do the built-in protections like unguessable action IDs provide?
What happens to a Server Action that is never used anywhere?
What is the point of a data-access layer for mutations?