← All posts
·7 min read·GuardLayer

Are Next.js Server Actions secure? The auth check everyone forgets

Next.jsServer ActionsAuthApp Router

Short answer: Server Actions are exactly as secure as the auth checks you put inside them — and by default there are none. The framework gives you an ergonomic way to mutate data straight from a component, and that ergonomics hides a sharp edge: every Server Action compiles down to a public HTTP endpoint. If your action doesn't check who's calling it, the answer to "is this safe?" is no.

This is the single most common Server Action bug we see, and it almost never looks like a bug. It looks like clean, modern App Router code.

What a Server Action actually is

When you mark a function with "use server", Next.js does something you don't see: it registers that function under a stable action ID and wires up an RPC endpoint for it. Calling the action from the client — whether via a <form action={...}> or an imported function — compiles down to a POST request to your own app, carrying that action ID and the serialized arguments.

That endpoint is reachable by anyone who can reach your app. It is not gated by your UI. The fact that the button which triggers deletePost only renders for logged-in admins is irrelevant — the UI is a suggestion, not a security boundary. An attacker doesn't need your button. They need the action ID (sitting in your shipped JavaScript) and a way to send a same-origin POST.

So a Server Action is best understood as an API route with no visible URL. You would never write an API route that deletes a row without checking the caller. Server Actions earn the same scrutiny — they just don't look like endpoints, which is exactly why the check gets forgotten.

The vulnerable pattern

Here's an app/actions/posts.ts module that deletes and updates posts. It compiles, it works in the demo, and it ships a remote-controlled "delete any post" capability to the entire internet:

"use server";

import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";

// Called from a <form action={deletePost}> in the dashboard.
export async function deletePost(formData: FormData) {
  const id = formData.get("id") as string;

  await prisma.post.delete({
    where: { id },
  });

  revalidatePath("/dashboard/posts");
}

export async function updatePost(formData: FormData) {
  const id = formData.get("id") as string;
  const title = formData.get("title") as string;

  await prisma.post.update({
    where: { id },
    data: { title },
  });

  revalidatePath("/dashboard/posts");
}
guardlayer scan · app/actions/posts.tsLive engine output
Passed with warnings
92/100 · A
  • Warningapp/actions/posts.ts:10

    Server Action without auth check

    At the top of every Server Action, resolve and verify the current user (e.g. const { user } = await getUser(); if (!user) throw …) and authorize the specific operation before mutating data. If you do guard it with a custom helper, this warning is a false positive.

Read deletePost again. It takes an id from the request and deletes that row. It never asks two questions that every mutation must answer:

  1. Authentication — is there a logged-in user at all?
  2. Authorization — is this user allowed to delete this specific post?

Without (1), an authenticated-or-not POST deletes data with no login. Without (2), any logged-in user can delete anyone's posts by changing the id — a textbook IDOR. Reading id (or even userId) from formData is not a check; the client controls that value entirely. The attacker just enumerates IDs.

Why this is dangerous, not just untidy

  • It's a real, exposed mutation endpoint. No exotic exploit required — a POST with the action ID invokes it directly. Next.js compares the Origin and Host headers to block trivial cross-site form posts, but that check is about where the request came from, not who sent it — it does nothing to establish that the caller is logged in or owns the row.
  • It scales. deleteMany/updateMany-shaped actions turn one request into a mass deletion.
  • It's invisible in review. The diff that introduced it looks like a feature, not a hole. Nothing in the type system, the build, or your tests complains. You find out from your logs — or from a user.

This is classified as CWE-862: Missing Authorization. It is consistently one of the most damaging web vulnerabilities precisely because it's an absence — there's nothing wrong on the screen, only something missing.

The fix

Treat the top of every mutating Server Action like the top of an API handler: resolve the caller, reject anonymous requests, then authorize the specific operation against the specific resource. Authentication (are you anyone?) is not authorization (are you allowed to touch this row?). You need both.

"use server";

import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { getCurrentUser } from "@/lib/session";

export async function deletePost(formData: FormData) {
  // 1. Authenticate: who is calling?
  const user = await getCurrentUser();
  if (!user) {
    throw new Error("Unauthorized");
  }

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

  // 2. Authorize: does THIS user own THIS post?
  //    Scope the mutation so it can only ever touch their own rows.
  const result = await prisma.post.deleteMany({
    where: { id, authorId: user.id },
  });

  if (result.count === 0) {
    throw new Error("Not found or forbidden");
  }

  revalidatePath("/dashboard/posts");
}

Two things to notice. First, the user comes from the session on the server (getCurrentUser() reading a cookie/JWT), never from formData. The client cannot forge it. Second, the authorization is pushed into the query with authorId: user.id. Using deleteMany with an ownership filter means a mismatched id deletes zero rows instead of someone else's data — the result.count === 0 branch turns that into an explicit error, and the database, not a console.log, enforces the rule.

If you're on Supabase (or Postgres directly), make the database the backstop with Row Level Security, so even a forgotten check can't cross tenants:

alter table posts enable row level security;

create policy "authors manage their own posts"
  on posts for all
  using (author_id = auth.uid())
  with check (author_id = auth.uid());

Note that RLS only protects you when the action talks to the database through a client that runs as the user — the request-scoped Supabase client carrying the user's JWT, never the service_role key, which bypasses every policy. For a whole module of actions, factor the guard into a small wrapper (const user = await requireUser()) so the check is impossible to forget — and make "every exported action calls it" a review rule.

30-second self-check

# Every file that declares Server Actions:
grep -rln '"use server"' app/ lib/

# Of those, which mutate data?
grep -rln '"use server"' app/ lib/ \
  | xargs grep -lE '\.(delete|update|insert|upsert)\(|prisma\.[a-z]+\.(delete|update|create|deleteMany|updateMany)'

# Now open each result: does it resolve the user from the SESSION
# (getUser/getSession/currentUser) before the mutation — not from the body?

If a file shows up in the second command but has no session lookup before the mutation, it's exploitable today. GuardLayer flags exactly this pattern — a "use server" module that mutates with no detectable auth guard — on every push, alongside 18 other checks across Next.js, Supabase, and general code, with the fix inline.

FAQ

Are Server Actions safe by default? They're safe to define, not safe to expose. The framework handles transport and basic same-origin (Origin/Host) checks, but it has no idea whether a given action requires a login. Authentication and authorization are entirely your job, inside each action.

Doesn't Next.js protect Server Actions from being called directly? It blocks trivial cross-origin form posts and obfuscates the endpoint behind a generated action ID, but the action is still a reachable POST endpoint. Obscurity is not authorization. Assume any action can be invoked by anyone who can load your app.

My action is only used behind a login. Isn't that enough? No. "Behind a login" usually means the button only renders for logged-in users. The endpoint doesn't check that. And even an authenticated user can tamper with the id to hit another user's data unless you authorize the specific resource.

Can I just check auth in middleware instead? Middleware is a useful first gate, but it's coarse (path-based) and easy to misconfigure. Per-resource authorization — "does this user own this row?" — has to live where the row is touched. Defense in depth: middleware and an in-action check and, ideally, RLS.

Is reading userId from formData an auth check? No — that's the bug, not the fix. The client controls every field in formData. The user identity must come from the server-side session.

Catch this before it ships — free

GuardLayer scans every push for this and 19 other Next.js + Supabase issues, with the exact fix inline.

Keep reading