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.
export const db = { post: { update: async (_args: { where: { id: string; authorId?: string }; data: { title?: string } }) => {}, },}'use server'
import { verifySession } from '@/app/lib/dal'import { db } from '@/lib/db'import { z } from 'zod'
const schema = z.object({ id: z.string().uuid(), title: z.string().min(1) })
export async function updatePost(formData: FormData) { const { userId } = await verifySession()
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: userId }, data: { title: parsed.data.title }, })}The CSRF model: Origin vs Host
Section titled “The CSRF model: Origin vs Host”Server Actions only accept POST, which rules out most CSRF vectors on its own — a cross-site <form> cannot forge a request that also carries the exact encrypted action ID. As a second layer, Next.js compares the request’s Origin header against its Host (or X-Forwarded-Host) header and rejects the request outright if they do not match, so an action can only be invoked from the same host that served the page. A request that carries no Origin header at all is let through with a warning rather than rejected outright.
Behind a reverse proxy or CDN where the public host differs from what the app server sees, configure serverActions.allowedOrigins with the host the browser shows, not the internal one:
import type { NextConfig } from 'next'
const config: NextConfig = { experimental: { serverActions: { allowedOrigins: ['my-proxy.com', '*.my-proxy.com'], }, },}
export default configEntries support wildcards: *.my-proxy.com matches exactly one subdomain label, **.my-proxy.com matches one or more. Note the option still lives under experimental in Next.js 16.3.6 even though Server Actions themselves have been stable since Next.js 14.
Body size limit
Section titled “Body size limit”Action requests are capped at 1MB by default, specifically to limit resource consumption from parsing oversized payloads. Raise it with serverActions.bodySizeLimit when you genuinely expect larger uploads — the limit applies to the raw HTTP body, including multipart/form-data boundaries and part headers, so leave some overhead room above the actual file size you expect.
import type { NextConfig } from 'next'
const config: NextConfig = { experimental: { serverActions: { bodySizeLimit: '2mb', }, },}
export default configThe encryption key for multi-instance deployments
Section titled “The encryption key for multi-instance deployments”Next.js encrypts the variables an inline action closes over before sending them to the client, using a private key generated fresh on every build. That is fine for a single instance, but if you self-host across multiple servers behind a load balancer, each instance builds its own key by default — an action invoked against one instance can fail to decrypt on another. Set NEXT_SERVER_ACTIONS_ENCRYPTION_KEY to a stable, shared value across all instances so closures stay decryptable no matter which instance handles the request:
# generate a compatible key: 16, 24, or 32 decoded bytes, base64-encodedopenssl rand -base64 32Set the resulting value as NEXT_SERVER_ACTIONS_ENCRYPTION_KEY in every instance’s environment. This is unrelated to the allowedOrigins/CSRF check above — it solves a different problem (decrypting closures consistently), not who is allowed to call the action.
Closures are encrypted, but not secret
Section titled “Closures are encrypted, but not secret”Encryption of closure variables raises the bar for casual inspection, but the official guidance is explicit: do not rely on encryption alone to keep a value out of the client’s hands. Treat anything closed over by an inline action the same way you would treat a value you were about to serialize into a client-visible prop — if it must never reach the browser, keep it out of the closure entirely and re-derive it from the session or the database inside the action instead.
async function getLatestVersion() { return 1}
export default async function Page() { const publishVersion = await getLatestVersion()
async function publish() { 'use server' if (publishVersion !== await getLatestVersion()) { throw new Error('The version has changed since pressing publish') } // ... }
return ( <form> <button formAction={publish}>Publish</button> </form> )}Unused actions are dead-code-eliminated — but the rule is narrower than it sounds
Section titled “Unused actions are dead-code-eliminated — but the rule is narrower than it sounds”An action that is genuinely never imported or called anywhere in your app is removed from the client-visible bundle at build time and never gets a callable public ID — that part is exactly what the docs promise. What it does not mean is that an action only becomes “used” once it is behind an authorization check. The moment an exported action is called from anywhere in your app — even a page nobody has linked to yet — it is a live, publicly reachable endpoint, full stop.
Building a probe with one action that a page actually renders and one that is never imported anywhere, then inspecting the build’s output confirms both halves of this directly:
used action's encrypted ID -> present in the page's rendered HTML (discoverable by any visitor)unused action's encrypted ID -> absent from every client-visible artifact (HTML, RSC payload, static JS)The server’s own internal routing table still lists the unused action’s ID (it needs some record to route requests by ID at all), but because that ID is never shipped to the browser in any form, no external caller can construct a request for it without already knowing a value they were never given. Treat this as raising the cost of discovery, not as a substitute for the authorization check inside the action.
'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 authorize-then-validate boilerplate, wrap actions in a helper that authorizes with verifySession(), parses with a schema, and returns a typed result. Every action then returns the same success-or-error shape, which pairs cleanly with useActionState.
import { verifySession } from '@/app/lib/dal'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 { userId } = await verifySession()
const parsed = schema.safeParse(Object.fromEntries(formData)) if (!parsed.success) return { ok: false, error: 'Invalid input' }
const data = await run(parsed.data, userId) return { ok: true, data } }}verifySession() redirects rather than returning null when there is no session, so this wrapper’s job narrows to validation and the typed result shape — authentication is already handled before the wrapper’s own code runs.
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.
import 'server-only'import { db } from '@/lib/db'
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{"Origin matches Host?"}
B -->|"No"| Z["Rejected — CSRF check fails"]
B -->|"Yes"| C{"Authorized user?"}
C -->|"No"| D["Reject: Unauthorized"]
C -->|"Yes"| E{"Input valid by schema?"}
E -->|"No"| F["Reject: Invalid input"]
E -->|"Yes"| G["Data-access layer with ownership check"]
G --> H["Mutate the database"]
Mental Model
Section titled “Mental Model”None of the framework’s built-in protections — the Origin/Host check, the body size limit, encrypted action IDs, dead-code elimination, closure encryption — are trying to answer “is this specific user allowed to do this specific thing.” They answer narrower questions: is this request even plausible, is it too large, can it be casually discovered, can it be casually inspected. Authorization is a question only your own code can answer, every single time.
Trade-offs
Section titled “Trade-offs”| Protection | What it actually guards against | What it does not do |
|---|---|---|
| Origin/Host check | Cross-site forged POST requests | Nothing against a same-origin caller with any payload |
bodySizeLimit (1MB default) | Resource exhaustion from oversized payloads | Nothing about whether the payload’s contents are valid |
| Encrypted action IDs + dead-code elimination | Casual discovery of an unused action’s endpoint | Authorization of a used action’s caller |
| Closure variable encryption | Casual inspection of captured values in transit | Keeping a value out of the client’s hands if it must stay secret |
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY | Consistent decryption across multiple self-hosted instances | Anything about who is allowed to call the action |
Common Misconceptions
Section titled “Common Misconceptions”- “Encrypted action IDs mean I don’t need to check who is calling.” They make the ID hard to find, not hard to use once found — anyone who can reach an authenticated session or observe the network can call it.
- “An action becomes safe once it’s dead-code-eliminated.” Dead-code elimination only applies to actions that are truly unused anywhere in the app — the moment any code path uses one, it is a live public endpoint regardless of whether that code path is auth-gated.
- “
allowedOriginsand the encryption key solve the same problem.” They do not — one is a CSRF allowlist for who may call the action at all, the other is about consistently decrypting closures across server instances. Neither substitutes for authorization inside the action.
Reading AI-generated code
Section titled “Reading AI-generated code”When a generated snippet touches Server Action security, check for these before trusting it:
- Does the action call an authorization check (
verifySession()or equivalent) as its first line, or does it assume the caller is already authenticated? - Is a secret or a value that must never reach the browser captured in an inline action’s closure, relying on encryption to keep it hidden?
- Does the code assume an action is safe because it is “not used in the UI yet” — without checking whether it is imported and callable from anywhere in the codebase?
- If the app sits behind a reverse proxy or CDN, does
next.configsetserverActions.allowedOriginsto the public host the browser sees, not the internal one the app server reports? - Does every mutation route through a data-access layer that checks ownership, or does the action query the database directly with only the caller-supplied ID?
🐛 Spot the bug
'use server'
import { z } from 'zod'
const schema = z.object({ id: z.string().uuid(), title: z.string().min(1) })
export async function updatePost(formData: FormData) {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 }, data: { title: parsed.data.title },})}Show the answer
This action validates its input with zod — that part looks solid — but it never calls verifySession() or any other authorization check, and the where clause only filters by id, not by the caller’s userId. Per the official data security guidance, “a page-level authentication check does not extend to the Server Actions defined within it” — this action is reachable directly, by anyone, and as written it lets any authenticated (or even unauthenticated, if no check exists elsewhere) caller update any post by guessing or observing its id. The fix is to call verifySession() first and add authorId: userId to the where clause, exactly like the updatePost example earlier in this lesson.