DEV Community

Anas Sheikh
Anas Sheikh

Posted on

Your Next.js Server Actions Are Public API Endpoints. Most Developers Don't Realize This.

Quick question. If you have a Server Action that deletes a user's account, called from a single delete button buried three clicks deep in account settings, how many people can actually trigger it?

If your answer is "only someone who clicks that specific button," I'd genuinely like you to go check, because the real answer is anyone who can send a POST request with the right shape, whether or not they ever saw your button at all.

Why This Surprises People

Server Actions feel like regular functions. You write 'use server', call it from onClick or a form action, and it just works, no fetch, no API route, no visible endpoint in your code. That ergonomics is genuinely great. It also creates a false sense that the function is somehow private to the component that calls it.

It isn't. Under the hood, Next.js compiles every Server Action into a real HTTP endpoint. Your component calling it is one way to reach that endpoint. It is not the only way. Anyone who inspects your client bundle, or just watches the network tab while using your app, can find the action's reference and call it directly, completely bypassing whatever UI, whatever button, whatever "you'd have to click three things to get here" flow you built around it.

What This Actually Looks Like

// actions/account.ts
'use server';
import { connectDB } from '@/lib/db';
import User from '@/models/User';

export async function deleteAccount(userId: string) {
  await connectDB();
  await User.findByIdAndDelete(userId);
  return { success: true };
}
Enter fullscreen mode Exit fullscreen mode

This looks completely reasonable if you're only thinking about the one delete button that calls it. The problem is userId is just a parameter. Nothing here checks whether the person calling this action is actually authenticated, let alone whether they're allowed to delete this specific account. Someone could call this with any user ID they want, and as far as this function is concerned, that's a perfectly valid request.

The Fix Is Not Complicated, It's Just Easy to Skip

// actions/account.ts
'use server';
import { connectDB } from '@/lib/db';
import User from '@/models/User';
import { getSession } from '@/lib/auth';

export async function deleteAccount() {
  const session = await getSession();
  if (!session) {
    throw new Error('Not authenticated');
  }

  await connectDB();
  // Delete the SESSION's user, never a userId passed in as a parameter
  await User.findByIdAndDelete(session.userId);

  return { success: true };
}
Enter fullscreen mode Exit fullscreen mode

Notice what changed. userId is gone as a parameter entirely. The action figures out who's making the request from the authenticated session, server-side, not from anything the caller supplies. Even if someone calls this endpoint directly, bypassing your UI completely, the worst they can do is delete their own account, because there is no longer any parameter that lets them specify someone else's.

This is the actual rule: any value a Server Action needs to know "who is this for" should come from the session, never from an argument. If you find yourself passing a userId into an action instead of deriving it from getSession(), that's worth a second look.

This Isn't Just About Deletion

The same gap shows up everywhere once you start looking for it:

// ❌ Trusts a role passed in from the client
export async function promoteToAdmin(userId: string, newRole: string) {
  await User.findByIdAndUpdate(userId, { role: newRole });
}

// ❌ Trusts a price passed in from the client
export async function createOrder(items: CartItem[], totalPrice: number) {
  await Order.create({ items, total: totalPrice }); // never trust a client-supplied price
}

// ❌ Trusts a tenant ID passed in from the client (the multi-tenancy nightmare)
export async function getInvoices(tenantId: string) {
  return Invoice.find({ tenantId });
}
Enter fullscreen mode Exit fullscreen mode

Every one of these takes something security-relevant as a parameter instead of deriving it server-side. Every one of them is callable directly, with whatever value an attacker chooses, by anyone who finds the endpoint, regardless of what your UI would normally send.

The Actual Checklist

Before shipping any Server Action, I ask three questions now:

Does this need to know who's calling it? If yes, that comes from getSession(), never a parameter.

Does this touch or return data scoped to a specific user, tenant, or role? If yes, that scope gets enforced server-side against the session, not trusted from anything passed in.

Would this still be safe if called directly, with any argument value, by someone who never saw my UI at all? If the honest answer is no, the action is missing a check, not a UI restriction, an actual server-side check.

Why This Matters More With AI-Assisted Coding

This gap has gotten easier to introduce, not harder, as AI coding tools have gotten better at quickly generating working Server Actions. A tool asked to "add a delete button that removes a user" will happily generate exactly the vulnerable version above, because it works, it satisfies the request, and nothing about "does this check who's calling it" is implied by that prompt. The code runs, the button works, the demo looks perfect. The gap only shows up when someone deliberately calls the endpoint directly, which almost never happens during normal testing.


Go check your own Server Actions right now, specifically anything that deletes, updates a role, or touches data that should be scoped to one user. If you find one trusting a client-supplied ID instead of the session, you're not alone, drop what you found in the comments. I'm genuinely curious how common this actually is across real projects.

Get the templates: https://pixelanas.gumroad.com


Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751

Top comments (0)