Next.js Server Actions security checklist: auth, validation, and CSRF (2026)
A practical checklist for securing Next.js Server Actions: treat each action as a public POST endpoint, re-check auth and ownership, validate input, limit return values, and configure allowedOrigins correctly.
Table of Contents
- 1-minute summary
- Who this is for
- Conclusion
- Explanation
- Why "the button is hidden" is not a security boundary
- What the framework already protects
- Schema validation is not authorization
- Return values are a data exposure surface
- Practical Guide
- Step 1: inventory every action
- Step 2: move auth into a server-only data layer
- Step 3: validate every argument at the boundary
- Step 4: shape return values and errors
- Step 5: handle closures and the encryption key deliberately
- Step 6: configure origins and body limits narrowly
- Step 7: add abuse controls for expensive actions
- Step 8: test the action without the UI
- Pitfalls
- Checklist
- FAQ
- 1. If Next.js encrypts action IDs, do I still need auth checks in every action?
- 2. Is Server Actions CSRF protection enough on its own?
- 3. Should I use Server Actions or Route Handlers for sensitive operations?
- 4. Can I put the auth check in Proxy to cover all actions at once?
- Sources
- Internal links
- Disclaimer
How do you secure Next.js Server Actions when anyone can call them?
1-minute summary
- A Server Action is a POST endpoint. Anyone who can send the same request can call it, even if your UI never shows the form.
- Check authentication, authorization, and resource ownership inside every action or in a
server-onlyData Access Layer that every action calls. Page, layout, and Proxy checks do not protect the action. - Validate every argument, derive identity from the session, return only what the UI renders, and configure
serverActions.allowedOriginsonly for the public hosts you actually serve.
Who this is for
- Teams building mutations with
'use server'in the Next.js App Router - Reviewers auditing AI-generated or fast-shipped Next.js code
- Engineers moving from API routes to Server Actions who want the same security bar
Conclusion
Server Actions remove the boilerplate of writing an API route, but they do not remove the API. The Next.js documentation says to treat every action as an untrusted entry point: the compiler replaces the function in client bundles with an action ID and a dispatcher that POSTs back to the server, and that request can be reproduced outside your UI.
Next.js adds useful framework protections: an Origin vs Host check against CSRF, a 1 MB default body limit, encrypted and rotating action IDs, dead code elimination for unused actions, and encryption of closure variables. None of them decides whether this user may change this record. That decision belongs to your code, close to the data.
A safe default for each action:
- read the session on the server
- parse and validate the input
- authorize the operation against the specific resource
- perform the mutation through a
server-onlydata layer - revalidate caches, then return a minimal result
Explanation
Why "the button is hidden" is not a security boundary
In the App Router, a page can redirect anonymous users while the form on that page still references an action. The Next.js data security guide states that a page-level authentication check does not extend to the Server Actions defined within it. The page controls which UI renders; the action is a separate entry point.
The same applies to layouts and Proxy (proxy.ts, called Middleware before Next.js 16). The authentication guide describes Proxy checks as optimistic and says Proxy should not be your only line of defense. Layouts do not re-render on every navigation, so a check there can also go stale.
What the framework already protects
These protections come from the current Next.js documentation (v16.3):
| Protection | What it does | What it does not do |
| --------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------- |
| POST-only invocation | Actions run only via POST, which avoids GET-triggered side effects | Stop a direct POST from an authenticated attacker |
| Origin vs Host check | Rejects actions when the Origin host differs from Host or X-Forwarded-Host | Reject requests that carry no Origin header |
| Body size limit | Caps the raw request body at 1 MB by default | Validate the content or rate-limit calls |
| Encrypted action IDs | Makes IDs non-deterministic; IDs are regenerated on new builds and cached for at most 14 days | Hide an action that your UI legitimately uses |
| Dead code elimination | Removes unused actions from client bundles so they get no public endpoint | Protect actions that are referenced anywhere |
| Closure variable encryption | Encrypts values captured by inline actions before they reach the client | Make it safe to capture secrets in closures |
Two details matter in reviews. First, the serverActions configuration reference says a request with no Origin header is allowed through with a warning rather than rejected. The CSRF check reduces browser-based risk; it is not authentication. Second, the data security guide explicitly recommends against relying on encryption alone to keep sensitive closure values off the client.
Schema validation is not authorization
A Zod or Valibot schema checks the shape of the input. The Next.js Server Actions guide points out that a well-formed object can still refer to a row the caller does not own. If an action accepts a full Item object from the client, including its ID and owner, anyone who can POST can modify any item.
The safer contract is: the client sends a reference (usually an ID) plus the requested change. The server derives the user from the session and loads the resource with an ownership condition. This is the same object-level authorization problem covered in our IDOR checklists, applied to a new transport.
Return values are a data exposure surface
Action return values are serialized and sent to the client. Returning db.user.update(...) directly can leak password hashes, internal flags, or other users' data from relations. Return an explicit object with only the fields the UI needs.
Practical Guide
Step 1: inventory every action
Search for the directive and list each exported or inline action with its caller, the data it touches, and who should be allowed to call it.
grep -rn --include='*.ts' --include='*.tsx' "'use server'" app lib src
Remember that dead code elimination only removes actions nothing references. Any action used by a form, a formAction, or useActionState is reachable.
Step 2: move auth into a server-only data layer
Keep actions thin and put the checks where every caller must pass through them:
// lib/dal/posts.ts
import 'server-only';
import { requireUser } from '@/lib/dal/session';
import { db } from '@/lib/db';
export async function renamePost(postId: string, title: string) {
const user = await requireUser(); // throws or redirects when no valid session
const post = await db.post.findFirst({
where: { id: postId, authorId: user.id },
select: { id: true },
});
if (!post) throw new Error('Not found');
await db.post.update({ where: { id: post.id }, data: { title } });
}
Using findFirst with the ownership condition returns the same result for "missing" and "not yours," which avoids confirming that another user's ID exists.
Step 3: validate every argument at the boundary
// app/posts/actions.ts
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { renamePost } from '@/lib/dal/posts';
const RenameInput = z.object({
postId: z.string().uuid(),
title: z.string().trim().min(1).max(120),
});
export async function renamePostAction(_prev: unknown, formData: FormData) {
const parsed = RenameInput.safeParse({
postId: formData.get('postId'),
title: formData.get('title'),
});
if (!parsed.success) return { ok: false as const, error: 'Invalid input' };
await renamePost(parsed.data.postId, parsed.data.title);
revalidatePath('/posts');
return { ok: true as const };
}
Parse FormData explicitly. Never spread form fields into an ORM data object, or a client can set columns such as role, authorId, or isPaid that the form never showed (mass assignment).
Step 4: shape return values and errors
Return a small discriminated result such as { ok: true } or { ok: false, error: 'Invalid input' }. Do not return raw records, stack traces, or messages that reveal whether a specific ID or email exists. Log detailed errors on the server with a request identifier instead.
Step 5: handle closures and the encryption key deliberately
Inline actions can capture render-time values, and Next.js encrypts them before they travel through the client. Capture only non-sensitive snapshots such as a version number, and re-read anything security-relevant on the server.
If you self-host across multiple instances, set the same NEXT_SERVER_ACTIONS_ENCRYPTION_KEY on every instance. The documented format is a base64-encoded AES key of 16, 24, or 32 bytes, for example from openssl rand -base64 32. Store it as a secret and plan its rotation like any other key.
Step 6: configure origins and body limits narrowly
Leave allowedOrigins unset when the browser host and the server host already match, including behind a proxy that forwards the public host in X-Forwarded-Host. Add entries only for the public hosts users actually see:
// next.config.js
module.exports = {
experimental: {
serverActions: {
allowedOrigins: ['app.example.com'],
bodySizeLimit: '1mb',
},
},
};
Wildcards follow specific rules: *.example.com matches exactly one label and does not match example.com itself, ** matches one or more labels and is allowed only at the start, and ports must be written out. Avoid broad patterns that include hosts other teams or tenants control. Raise bodySizeLimit only for the actions that need uploads, and leave room for multipart overhead.
Step 7: add abuse controls for expensive actions
Actions that send email, call paid APIs, or write heavily need rate limiting keyed by user and IP. For destructive operations, the Next.js guide suggests stronger handling such as elevated session checks or re-authentication, and a loud failure when those checks miss.
Step 8: test the action without the UI
Write a test that calls the action module directly as an anonymous user, as a user who does not own the resource, and with extra form fields. Each call should fail without changing data. In review, the question is not "can the UI trigger this?" but "what happens if anyone posts this?"
Pitfalls
- relying on a page redirect, layout check, or Proxy matcher to protect an action
- checking that a user is logged in but not that they own the record
- accepting full objects, owner IDs, or roles from the client
- spreading
Object.fromEntries(formData)into an ORM update - returning raw database records from an action
- capturing tokens, secrets, or private fields in inline action closures
- assuming the CSRF check authenticates the caller, or that missing
Originis rejected - adding broad
allowedOriginswildcards to silence an error instead of fixingX-Forwarded-Host - raising
bodySizeLimitglobally for one upload form - leaving email, AI, or payment actions without rate limits
- exporting unused helper functions from a
'use server'file - surfacing detailed database errors to the client
Checklist
- [ ] Every
'use server'file and inline action is inventoried with its caller and data scope - [ ] Each action verifies the session on the server, not only in the page, layout, or Proxy
- [ ] Each action checks authorization for the specific resource, not just login state
- [ ] Ownership is enforced in the query (for example
idplusauthorId) - [ ] User identity comes from the session, never from form fields or arguments
- [ ] All arguments are parsed with a schema that sets types, lengths, and formats
- [ ] ORM writes list allowed fields explicitly; no form spreading
- [ ] Database access goes through a
server-onlyData Access Layer - [ ] Return values are explicit minimal objects, not raw records
- [ ] Error messages do not reveal whether other users' IDs or emails exist
- [ ] Inline closures capture no secrets or private data
- [ ] Self-hosted multi-instance deployments share a stable
NEXT_SERVER_ACTIONS_ENCRYPTION_KEYstored as a secret - [ ]
allowedOriginsis unset or limited to the exact public hosts you serve - [ ]
bodySizeLimitis raised only where uploads require it - [ ] Expensive or abusable actions have rate limits
- [ ] Destructive actions require stronger checks or re-authentication
- [ ] Tests call actions directly as anonymous, non-owner, and over-posting users
- [ ] The UI handles "Failed to find Server Action" after deployments with a retry path
FAQ
1. If Next.js encrypts action IDs, do I still need auth checks in every action?
Yes. The documentation says encrypted IDs reduce risk when an authentication layer is missing, but you should still treat actions as reachable by direct POST and verify authentication and authorization inside each one. A legitimate user of your app receives the action ID in their client, so the ID is not a secret.
2. Is Server Actions CSRF protection enough on its own?
It covers a specific browser attack: a cross-site form post. Actions only accept POST, and Next.js rejects requests whose Origin host does not match the app's host. Requests with no Origin header are allowed through with a warning, and a logged-in attacker does not need CSRF at all. Keep SameSite session cookies and authorize every action.
3. Should I use Server Actions or Route Handlers for sensitive operations?
Both need the same checks, and the Next.js authentication guide tells you to treat both as public-facing endpoints. Choose Server Actions for mutations triggered by your own UI. Choose Route Handlers when external clients, webhooks, or non-mutation requests need a stable URL and HTTP semantics.
4. Can I put the auth check in Proxy to cover all actions at once?
Use Proxy for optimistic redirects based on the session cookie, not as the only protection. The authentication guide says Proxy should not be the only line of defense and that most security checks should run close to the data source.
Sources
- How to think about data security in Next.js (Next.js Docs)
- Server Actions and Mutations (Next.js Docs)
- serverActions configuration (Next.js Docs)
- How to implement authentication in Next.js (Next.js Docs)
- Insecure Direct Object Reference Prevention Cheat Sheet (OWASP)
- Mass Assignment Cheat Sheet (OWASP)
Internal links
- Parent hub: Next.js security
- Related:
Disclaimer
General engineering and security guidance only. Framework behavior changes between versions; confirm details against the Next.js version you run, your authentication library, and your deployment platform, and test your own actions before relying on them.
Popular
- 1Permit2 explained (Web3): why approvals changed and how to use it safely (checklist)
- 2Read wallet signing screens (Web3): a 30-second checklist to avoid permission traps
- 3Spec-to-implementation prompt template (AI development): how to stop the model from guessing
- 4Revoke token approvals on EVM: how to audit allowances safely (checklist)
- 5Clarifying questions checklist (AI development): what to ask before you let an LLM build