Stop treating Server Actions like internal functions
By Daniel Ensminger
Server Actions are public HTTP endpoints, not private helpers, and it's time we started securing them that way.
July 1, 2026
It’s mid-2026, and Next.js Server Actions have been a stable, mature feature for years. Yet, I am still auditing codebases where developers write them like private, internal helper functions.
Let's get one thing straight: Server Actions are public HTTP endpoints. They are APIs. If you write a server action, you are opening a door directly to your server. Treat it with the exact same paranoia you use for REST endpoints and GraphQL resolvers.
Validation is not optional
When you define an action that takes a string, TypeScript promises you it's a string. The network makes no such promise.
Client-side types are a developer experience feature, not a security boundary. You must treat all incoming arguments as untrusted public input, regardless of how strict your client-side forms are. Any malicious user can bypass your UI and hit your Server Action endpoint with a crafted payload.
Enforce strict schema validation using Zod or Valibot at the very top of your action. This blocks payload injection before your business logic even boots up.
import { z } from 'zod';
const UpdateUserSchema = z.object({
email: z.string().email(),
age: z.number().min(18).max(120),
});
export async function updateUser(rawInput: unknown) {
// 1. Sanitize and validate immediately at the entry point
const parsed = UpdateUserSchema.safeParse(rawInput);
if (!parsed.success) {
return { error: "Invalid payload provided." };
}
const data = parsed.data;
// proceed with business logic...
}Sanitize your data immediately. Do not pass raw FormData or untyped objects deeper into your service layers. Validate strictly at the entry point to prevent common web vulnerabilities like NoSQL injection or prototype pollution.
The manual authorization mandate
A dangerous myth refuses to die: "My Next.js middleware checks the session token, so my Server Actions are secure."
This is dangerously false. Middleware secures route navigation, but Server Actions operate like RPC calls. Depending on how they are invoked, relying solely on path-based middleware leaves you completely exposed to direct endpoint POST requests.
More importantly, middleware lacks context. It might know who is logged in, but it doesn't know if that specific user has permission to delete Invoice #4092. You must perform explicit authorization checks inside every single action body.
Failure to do so leads straight to Insecure Direct Object Reference (IDOR) vulnerabilities. Verify user permissions within the server-side closure before executing state-mutating logic.
export async function deleteInvoice(invoiceId: string) {
const session = await getSession();
if (!session?.user) throw new Error("Unauthorized");
const invoice = await db.invoices.findUnique({ where: { id: invoiceId } });
// Explicit IDOR protection within the closure
if (invoice.ownerId !== session.user.id) {
throw new Error("Forbidden");
}
await db.invoices.delete({ where: { id: invoiceId } });
}
Standardizing with Action Wrappers
Manually writing session checks, schema validation, and try/catch blocks in every action leads to massive boilerplate. Worse, it leads to human error. Eventually, you will forget a check.
Adopt the "Action Wrapper" pattern. Centralize your session verification, logging, and error handling into a reusable higher-order function.
This abstraction is also the perfect layer to integrate Redis-based rate limiting. Server Actions are prime targets for automated brute-force attacks and credential stuffing. A wrapper lets you thwart these attacks globally or per-user via Upstash or a similar Redis provider without muddying your core business logic.
import { actionClient } from '@/lib/safe-action';
import { rateLimit } from '@/lib/redis';
// Reusable security layer minimizes boilerplate and human error
export const secureAction = actionClient
.use(async ({ next }) => {
const session = await verifySession();
if (!session) throw new Error("Unauthorized");
// Thwart automated brute-force attacks
await rateLimit(session.userId);
return next({ ctx: { userId: session.userId } });
});
export const updateProfile = secureAction
.schema(ProfileSchema)
.action(async ({ parsedInput, ctx }) => {
// Safe, typed, authorized, and rate-limited
return db.users.update({
where: { id: ctx.userId },
data: parsedInput
});
});Minimize your risk surface by forcing all sensitive logic through this standardized security layer.

Safeguarding the client-side UI
What happens when your action fails? If you are throwing raw database errors, you are leaking internal infrastructure details directly to the client.
Leverage React's useActionState to manage UI states and errors gracefully. Ensure server-side failures return generic, safe messages to the frontend. Never expose stack traces or SQL syntax errors to your users.
Furthermore, stop returning internal sequential database IDs to the DOM. If your action creates a new project, don't pass projectId: 5432 back to the client. Replace internal database IDs with encrypted tokens, or use obfuscated public identifiers like NanoIDs or UUIDv7.
'use client';
import { useActionState } from 'react';
import { createProject } from './actions';
export function ProjectForm() {
// Managing state cleanly without exposing server internals
const [state, formAction, isPending] = useActionState(createProject, null);
return (
<form action={formAction}>
<input name="title" required />
<button disabled={isPending}>Create</button>
{state?.error && <p className="text-red-500">{state.error}</p>}
</form>
);
}Server Actions are incredibly powerful, but that power requires strict discipline. Stop treating them as magic functions that live in a trusted vacuum. They are public doors to your database. Validate strictly, authorize manually, wrap everything in a secure layer, and leak nothing to the client.
Go audit your actions.ts files today.