Skip to content

· 8 min read

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 inWhat it means
Encrypted, non-deterministic action IDsThe client calls an action by an ID, not by its name. IDs change between builds.
Dead-code eliminationActions your app never uses are removed at build time and don't become endpoints.
POST onlyActions can't be triggered by a link, an image tag or a prefetch.
Origin checkWhen the browser sends an Origin header, it must match the host. That blocks most cross-site request forgery.
Body size limitRequest 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.allowedOrigins in next.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":

#QuestionFix if not
1Does the action check the session itself?Call your auth helper first thing
2Does it check the caller owns the record?Compare the owner ID before reading or writing
3Are all arguments validated on the server?Schema or validation function
4Is database and secret access in a server-only layer?Move it, keep the action thin
5Does it return only what the UI needs?Pick fields, generic errors
6Are sessions in httpOnly, secure cookies?Move tokens out of localStorage
7Are public or sensitive actions rate-limited?Shared-store limiter, honeypot, host firewall
8Does anything mutate data on GET?Move it to a Server Action or a POST handler
9Are uploads going through the action body?Signed direct-to-storage uploads
10Does 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.

  • Next.js
  • Security
  • Server Actions
  • Full-stack