← All guides

Next.js Server Actions Security for AI-Built Apps

Updated 2026-10-14

Server Actions are one of the nicest parts of the Next.js App Router. You write an async function with "use server", call it from a form, and skip building an API route. AI tools love them for the same reason.

The catch: a Server Action is an API endpoint. Next.js gives each one an ID and exposes it as a POST request. Anyone who can load your page can find that ID and call the action with whatever arguments they want. Hiding the button doesn't hide the action.

This guide covers what to check in every Server Action before you launch.

A typical AI-generated action

// app/actions.ts  (don't ship this)
"use server";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";

export async function updateProfile(userId: string, data: { name: string; plan: string }) {
  await db.profile.update({ where: { userId }, data });
  revalidatePath("/settings");
}

There are three problems:

  1. No authentication. A logged-out visitor can call it.
  2. userId comes from the caller, so any user can update anyone's profile.
  3. data is written as-is, so a user can set plan: "pro" for free.

Rule 1: Authenticate inside the action

Treat the start of every action like the start of an API route:

"use server";
import { createClient } from "@/lib/supabase/server";

async function requireUser() {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) throw new Error("Unauthorized");
  return { user, supabase };
}

Don't rely on the page or layout having checked already. Layouts don't re-run on every navigation, and the action can be called without loading your page at all. Put the check in the action itself.

If you use Auth.js, the equivalent is const session = await auth() at the top of the action. With Clerk, const { userId } = await auth().

Rule 2: Get identity from the session, not arguments

export async function updateProfile(input: unknown) {
  const { user, supabase } = await requireUser();
  // ... validate input (see below)
  await supabase.from("profiles").update({ name }).eq("id", user.id);
}

The action never accepts a user ID. It always works on the signed-in user's own row.

When an action does take a record ID, like deletePost(postId), check ownership in the query:

export async function deletePost(postId: string) {
  const { user, supabase } = await requireUser();
  const { data } = await supabase
    .from("posts")
    .delete()
    .eq("id", postId)
    .eq("author_id", user.id)
    .select("id");
  if (!data?.length) throw new Error("Not found");
  revalidatePath("/posts");
}

With Supabase, correct RLS policies give you a second layer here. See the Supabase RLS checklist.

Rule 3: Validate every argument

TypeScript types are erased at runtime. If your action is typed as (name: string), a caller can still send an object, a number, or a 10 MB string. Validate with a schema:

"use server";
import { z } from "zod";
import { revalidatePath } from "next/cache";

const ProfileInput = z.object({
  name: z.string().trim().min(1).max(80),
  bio: z.string().max(500).optional(),
});

export async function updateProfile(formData: FormData) {
  const { user, supabase } = await requireUser();

  const parsed = ProfileInput.safeParse({
    name: formData.get("name"),
    bio: formData.get("bio") ?? undefined,
  });
  if (!parsed.success) return { error: "Invalid input" };

  const { error } = await supabase
    .from("profiles")
    .update(parsed.data)
    .eq("id", user.id);
  if (error) return { error: "Could not save" };

  revalidatePath("/settings");
  return { ok: true };
}

Because the schema only lists name and bio, fields like plan, role or is_admin can't sneak through. Never spread a whole FormData or request object into an update.

Rule 4: Be careful what you return

Whatever an action returns is sent to the browser. AI-generated code often returns the full database row, which might include internal fields, other users' emails in a joined relation, or tokens.

Return only what the UI needs:

return { ok: true, name: parsed.data.name };

The same applies to errors. Return a generic message and log the details on the server, instead of sending a raw database error to the client.

Rule 5: Don't pass secrets through closures

You can define a Server Action inline inside a Server Component, and it can use variables from the surrounding scope. Next.js encrypts those captured values before sending them to the client, but the docs still advise against relying on that for sensitive data. Keep secrets in environment variables read inside the action, and don't capture things like API keys or other users' data in closures.

Rule 6: Mark server-only modules

Put database and secret-handling code in modules that start with:

import "server-only";

If a client component ever imports that module, the build fails instead of quietly bundling your server code into the browser. It's a cheap guard against AI tools moving code around.

Rule 7: Rate limit expensive actions

Actions that send email, call an LLM, or create records can be called in a loop. Add rate limiting keyed on the user ID (or IP for logged-out actions). The approach is the same as for API routes, covered in rate limiting OpenAI API routes.

What Next.js does for you

It's worth knowing what's already handled:

None of that replaces auth checks. Origin checks stop other websites from calling your actions with a visitor's cookies. They don't stop a logged-in user, or a script, from calling the action directly.

A quick audit

Search your repo for every action:

grep -rn '"use server"' app lib src 2>/dev/null

For each exported function in those files, ask:

The same questions apply to route handlers. See securing Next.js API routes with auth.

Get a second look

It's easy to fix one action and miss the four others that an AI tool added last week. PreflightPR reviews your repo for unprotected server code, leaked secrets and missing RLS, and turns what it finds into a launch score. Run a free scan on a public repo to see where you stand. It's an automated best-effort check, not a full audit.

Check your repo in about a minute

Paste a public GitHub URL and get a free launch score.

Run a free scan

More guides