7 min read

Next.js Server Actions security — the bugs everyone ships and the fixes

Server Actions are Next.js's RPC mechanism — async functions marked "use server" that run on the server but are called from client components. The convenience hides the risk: every Server Action is an unauthenticated public API endpoint by default. Here is the vulnerable pattern, the fix, and the audit checklist.

Every Server Action you ship is a public, unauthenticated API endpoint by default. The `"use server"` directive turns the function into a callable RPC any browser can hit. The auth + ownership checks the framework does not provide are your job. Most AI-generated Server Actions get this wrong.

What it is

Server Actions are async functions marked with `"use server"` (either at the top of a file or inline). When a client component calls one, Next.js packages the call as a POST to a serialized endpoint. The function body runs on the server. Critically: the serialized endpoint is exposed and callable by anyone with the URL — there is no implicit auth boundary.

Vulnerable example

// app/actions/orders.ts
"use server";
import { db } from "@/lib/db";

// Bug: takes user_id from FormData and trusts it. Anyone who calls
// this Server Action with someone else's user_id reads their order.
export async function getOrder(formData: FormData) {
  const userId = formData.get("userId") as string;
  const orderId = formData.get("orderId") as string;
  return await db.from("orders")
    .select("*")
    .eq("id", orderId)
    .eq("user_id", userId)  // <-- attacker controls this
    .single();
}

Fixed example

// app/actions/orders.ts
"use server";
import { db } from "@/lib/db";
import { getAuthenticatedUser } from "@/lib/auth";

export async function getOrder(formData: FormData) {
  // 1. Resolve the user from the verified session — never from FormData.
  const user = await getAuthenticatedUser();
  if (!user) {
    throw new Error("unauthorized");
  }

  const orderId = formData.get("orderId") as string;

  // 2. Filter by the AUTHENTICATED user, not by client-supplied user_id.
  const { data: order } = await db.from("orders")
    .select("*")
    .eq("id", orderId)
    .eq("user_id", user.id)
    .single();

  // 3. If RLS is on the table (it should be), the .eq("user_id", ...)
  //    filter is redundant defense-in-depth. Either alone is a bug;
  //    both together is correct.
  if (!order) {
    throw new Error("not found");  // 404 shape — don't reveal existence
  }
  return order;
}

How Securie catches it

Securie findingcritical
apps/web/app/api/route.ts:22

Next.js Server Actions security

Securie's BOLA/BFLA specialist (Day-1, production-validated) reads every Server Action, identifies the data flow from `formData.get(...)` to the database query, and runs a cross-user attack in the sandbox: signs in as user A, calls the Server Action with user B's ID, observes whether the response contains user B's data. If the cross-user request succeeds, the finding ships with the reproduced exploit + the fixed Server Action as a Suggested Change. The intent-graph reasoning catches the structural pattern even when surface-level pattern matchers miss it (e.g., when the user_id is renamed or wrapped in a Zod schema that does not enforce ownership).

Suggested fix — ready as a PR
// app/actions/orders.ts
"use server";
import { db } from "@/lib/db";
import { getAuthenticatedUser } from "@/lib/auth";

export async function getOrder(formData: FormData) {
  // 1. Resolve the user from the verified session — never from FormData.
  const user = await getAuthenticatedUser();
  if (!user) {
    throw new Error("unauthorized");
  }

  const orderId = formData.get("orderId") as string;

  // 2. Filter by the AUTHENTICATED user, not by client-supplied user_id.
  const { data: order } = await db.from("orders")
    .select("*")
    .eq("id", orderId)
    .eq("user_id", user.id)
    .single();

  // 3. If RLS is on the table (it should be), the .eq("user_id", ...)
  //    filter is redundant defense-in-depth. Either alone is a bug;
  //    both together is correct.
  if (!order) {
    throw new Error("not found");  // 404 shape — don't reveal existence
  }
  return order;
}
Catch this in my repo →Securie scans every PR · ships the fix as a one-click merge · free during early access

Checklist

  • Every Server Action calls `getAuthenticatedUser()` (or equivalent) FIRST, before any database read or write.
  • Server Actions never read `user_id` from FormData / arguments — always from the verified session.
  • Database queries filter by the authenticated user's ID, not by client-supplied identifiers.
  • RLS is enabled on every Supabase table the Server Actions touch (defense in depth).
  • Errors return generic messages (`'not found'`) — never reveal whether a resource exists.
  • Sensitive Server Actions (admin, billing, account-deletion) check role beyond auth presence.
  • Rate limiting on Server Actions that interact with external APIs or send emails.

FAQ

Doesn't `"use server"` mean only my server can call it?

No — it means the function runs on the server. The endpoint Next.js generates from it is publicly callable. Anyone with the URL (which is in your client bundle) can invoke it with arbitrary inputs. `"use server"` is a runtime location directive, not an access boundary.

If I'm using Supabase RLS, do I still need to check auth in the Server Action?

Yes. RLS protects the database; it does not protect business logic in your Server Action. If your Server Action calls `db.from('orders').select(...)` without an auth check, RLS catches it (good); if your Server Action calls a third-party API, sends an email, or charges Stripe, RLS does nothing and you ship the bug. Defense in depth: RLS + auth check at the entry of every Server Action.

What about CSRF protection?

Next.js 14+ has built-in CSRF protection for Server Actions via origin-header validation, on by default for App Router. Earlier versions or custom configurations may not. Verify your `next.config.js` doesn't disable it; if you're on a Next.js version below 14, audit explicitly.

How do I find every Server Action in my repo?

Search for the `"use server"` directive: `grep -rn 'use server' app/ lib/ src/`. Each match is an action. For each, verify the auth check is the first thing the function does. AI-generated code (Cursor, Copilot, Lovable) tends to skip the auth check; manual review catches most, automated review (Securie) catches all.

Related guides