Securing Next.js Server Actions: a 10-point checklist for 2026
Every exported Server Action is a public POST endpoint. Here's what Next.js protects for you, what it doesn't, and the ten checks I put on every action: auth, ownership, validation, cookies, rate limits and more.
Vishal Solanki · Full-stack developer, Noida
Server Actions are the nicest way to write mutations in Next.js. You write an async function, mark it "use server", pass it to a form, and you're done: no API route, no fetch call, no loading-state plumbing.
That convenience hides one fact you need to keep in mind every time: each exported Server Action is a public HTTP endpoint. Anyone can call it with a POST request, from anywhere, with any arguments, whether or not your UI shows the button.
At Ambak I moved sensitive operations out of the browser and into server-side code built on Server Actions and secure cookies. This is the checklist I use for every action since.
What Next.js already does for you
It's worth knowing exactly where the built-in protection stops.
| Built in | What it means |
|---|---|
| Encrypted, non-deterministic action IDs | The client calls an action by an ID, not by its name. IDs change between builds. |
| Dead-code elimination | Actions your app never uses are removed at build time and don't become endpoints. |
| POST only | Actions can't be triggered by a link, an image tag or a prefetch. |
| Origin check | When the browser sends an Origin header, it must match the host. That blocks most cross-site request forgery. |
| Body size limit | Request bodies are capped at 1 MB by default. |
What it does not do: check who is calling, check whether they're allowed to touch this record, validate the arguments, or limit how often they can call. Those are your job, inside every action.
1. Authenticate inside the action, every time
A login check on the page does not protect the actions defined on that page. The page check runs when the page renders. The action runs later, on its own request, and can be called without ever loading the page.
"use server";
import { getSession } from "@/lib/auth";
export async function updateProfile(formData: FormData) {
const session = await getSession();
if (!session) throw new Error("Not signed in");
// ...
}
2. Authorise the specific record
Being signed in only proves who the caller is. It doesn't prove they own the thing they're changing. If an action takes an ID, check ownership, or someone will change the ID and edit another user's data. This bug has a name, insecure direct object reference (IDOR), and it's one of the most common ones I find.
export async function deleteAddress(addressId: string) {
const session = await getSession();
if (!session) throw new Error("Not signed in");
const address = await db.address.findUnique({ where: { id: addressId } });
if (!address || address.userId !== session.userId) throw new Error("Not found");
await db.address.delete({ where: { id: addressId } });
}
Return the same "Not found" whether the record doesn't exist or belongs to someone else, so the action can't be used to probe which IDs exist.
3. Validate every argument
TypeScript types disappear at runtime. A caller can send any shape, any length and any type. Validate on the server with a schema:
import { z } from "zod";
const Profile = z.object({
name: z.string().trim().min(1).max(100),
email: z.string().trim().email().max(200),
city: z.string().trim().max(80).optional(),
});
export async function updateProfile(formData: FormData) {
const session = await getSession();
if (!session) throw new Error("Not signed in");
const parsed = Profile.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: "Please check the highlighted fields." };
await db.user.update({ where: { id: session.userId }, data: parsed.data });
return { ok: true };
}
You don't need a library for small forms. A plain function that trims, checks lengths and checks values against a fixed list works just as well, as long as it runs on the server and has a small test.
4. Keep the database and secrets behind a server-only layer
Put database access, API keys and permission checks in one data access layer, and mark it so it can never end up in a client bundle:
// lib/data/addresses.ts
import "server-only";
export async function deleteAddressFor(userId: string, addressId: string) {
// ownership check + delete, as in step 2
}
Then actions stay thin: read the session, validate the input, call the data layer. Auditing one folder is much easier than auditing every "use server" function in the app.
5. Return only what the client needs
Whatever an action returns is sent to the browser. Don't return the whole database row with password hashes, internal flags or other users' data. Pick the fields the UI needs.
The same goes for errors. Log the real error on the server, and send the user a short, generic message. A stack trace or a raw SQL error tells an attacker far more than it tells your user.
6. Keep sessions in httpOnly, secure cookies
Tokens in localStorage can be read by any script that runs on your page, including a compromised third-party one. An httpOnly cookie can't. Set it from a Server Action or Route Handler, where Next.js can send the Set-Cookie header:
import { cookies } from "next/headers";
export async function signIn(formData: FormData) {
// ...verify credentials, create a session...
(await cookies()).set("session", sessionId, {
httpOnly: true, // not readable from JavaScript
secure: true, // HTTPS only
sameSite: "lax", // not sent on most cross-site requests
path: "/",
maxAge: 60 * 60 * 24 * 7, // one week
});
}
Store a random session ID in the cookie and keep the session data on the server, or sign or encrypt the cookie's contents. Never put anything in a cookie you'd be unhappy for the user to edit.
7. Rate-limit, and stop the obvious bots
Sign-in, sign-up, password reset, OTP and contact forms get hammered. Limit how often one client can call them:
import { headers } from "next/headers";
import { rateLimit } from "@/lib/rate-limit"; // backed by Redis or your host's KV store
export async function requestOtp(formData: FormData) {
const ip = (await headers()).get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
if (!(await rateLimit(`otp:${ip}`, { limit: 5, windowSeconds: 600 }))) {
return { error: "Too many attempts. Try again in a few minutes." };
}
// ...
}
The limit has to live in a shared store, not in a variable in memory, because serverless functions don't share memory between instances. Turn on your host's firewall or bot protection as well.
For public forms, a hidden honeypot field catches most simple bots cheaply: real people never fill it in, so any submission that does can be quietly dropped. The contact form on this site works that way.
8. Understand the CSRF protection, and don't break it
Next.js compares the request's Origin header with the host the request arrived at, and rejects actions that don't match. That covers most cross-site request forgery, together with SameSite cookies.
Two ways to break it by accident:
- Behind a reverse proxy that forwards its own host instead of the public one, valid requests start failing. Add the public host to
experimental.serverActions.allowedOriginsinnext.config.ts, rather than disabling anything. - Mutations in GET Route Handlers. A GET request can be triggered by a link or an image tag from any site. Anything that changes data should be a Server Action or a POST, PUT or DELETE handler.
9. Mind the size of what's sent
The 1 MB default body limit is there to stop huge payloads from tying up your server. If you need file uploads, don't just raise the limit: upload straight from the browser to object storage with a short-lived signed URL, then send the resulting file key to a Server Action.
10. Keep Next.js patched
Framework bugs are part of your attack surface too. On 14 October 2026, Next.js fixed two critical remote code execution flaws (one in image optimisation of SVG files, CVE-2026-96889, and one in use cache functions returning typed arrays) and a high-severity denial-of-service issue in App Router apps that use Server Actions. The fixes are in 16.4.1 and 15.5.28. Make sure sharp resolves to 0.35.5 or later as well.
Run npm audit regularly, read the advisories it reports, and update to the patched release. If you also run PHP anywhere, see PHP2Shell: is your website affected.
The audit checklist
Run through this for every file that contains "use server":
| # | Question | Fix if not |
|---|---|---|
| 1 | Does the action check the session itself? | Call your auth helper first thing |
| 2 | Does it check the caller owns the record? | Compare the owner ID before reading or writing |
| 3 | Are all arguments validated on the server? | Schema or validation function |
| 4 | Is database and secret access in a server-only layer? | Move it, keep the action thin |
| 5 | Does it return only what the UI needs? | Pick fields, generic errors |
| 6 | Are sessions in httpOnly, secure cookies? | Move tokens out of localStorage |
| 7 | Are public or sensitive actions rate-limited? | Shared-store limiter, honeypot, host firewall |
| 8 | Does anything mutate data on GET? | Move it to a Server Action or a POST handler |
| 9 | Are uploads going through the action body? | Signed direct-to-storage uploads |
| 10 | Does npm audit report Next.js advisories? | Update to the patched version |
FAQ
Are Server Actions secure?
They're as secure as the code inside them. The transport is solid: POST only, an origin check and unguessable IDs. But authentication, authorisation, validation and rate limiting are always your responsibility, just as they would be in an Express route.
Should I use Server Actions or Route Handlers for mutations?
Use Server Actions for mutations that come from your own UI. Use Route Handlers when something outside your app needs to call you, like a webhook, a mobile app or a partner integration. I compare the options in detail in Next.js as a backend: Server Actions, Route Handlers or a separate Node.js API.
Do I need my own CSRF tokens?
Usually not. The built-in origin check plus SameSite cookies covers the common cases. You'd add tokens if you have to support very old browsers, or you've deliberately relaxed cookie or origin settings.
Want a security review of your Next.js app's actions and API routes? Get in touch.