Security and Patterns
The idea in one sentence
Section titled “The idea in one sentence”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.
Validate and authorize inside the action
Section titled “Validate and authorize inside the action”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 resultimport { 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 } }}Keep DB access in a data-access layer
Section titled “Keep DB access in a data-access layer”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 tableimport '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"]