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.

4 min read

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 } });
}

A precise flat vector bar chart comparing 'Validation Latency' vs 'Total Execution Time' in millisec

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.

A structural data flow diagram showing a request bypassing 'Route Middleware' to hit an 'Action Hand

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.

👍
❤️
🔥
👏
🤯

Join 2,000 readers and get infrequent updates on new projects.

+8.7K

I promise not to spam you or sell your email address.