Lesson 50 of 81
A Server Action isn't 'called from your button' — it's a POST endpoint at a URL, and anyone who can send that POST can invoke it. Every check that matters has to live inside the function, not upstream of it.
14 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
Every 'use server'-marked function compiles to two things: an encrypted reference the client bundle can call, and a real POST route on the server that the reference dispatches to. When your button's onClick "calls" the action, what's actually happening is a fetch to that route. That means the route exists whether or not your UI ever renders the button — a script, a browser extension, or an attacker with curl can send the same POST directly, skipping your component tree entirely.
Next.js gives you real protection at the framework level, and it's worth naming so you don't duplicate it by hand: encrypted, non-deterministic action IDs (so guessing a route is impractical and IDs rotate across builds), dead-code elimination (an action never referenced anywhere in your app isn't shipped as a callable endpoint at all), and an Origin-vs-Host check on every request, which — combined with the fact that actions only accept POST and modern browsers default cookies to SameSite=Lax — closes off most classic CSRF. None of that is authentication, and none of it is authorization. It stops a stranger's site from tricking a logged-in browser into firing your action; it does nothing to stop a logged-in — or entirely anonymous — attacker from calling the action directly and supplying whatever arguments they like.
// app/items/actions.ts
"use server";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
// Looks guarded: only rendered behind a signed-in check on the page.
// Isn't: nothing here stops a direct POST from a different logged-in
// user, and nothing stops them from deleting someone else's item.
export async function deleteItemUnsafe(itemId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
await db.item.delete({ where: { id: itemId } });
}
The auth() check only proves someone is logged in — it never confirms this session owns itemId. Any authenticated user can pass any other user's ID and delete their row. This is IDOR: a well-formed request pointing at a resource the caller doesn't own. The hardened version re-derives ownership from the trusted session instead of trusting the client-supplied ID on its own:
// app/items/actions.ts
"use server";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
export async function deleteItem(itemId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
// Scope the lookup to this session's own rows — an ID for an item
// the caller doesn't own simply won't be found.
const item = await db.item.findFirst({
where: { id: itemId, ownerId: session.user.id },
});
if (!item) throw new Error("Not found");
await db.item.delete({ where: { id: item.id } });
return { success: true }; // not the deleted row — see below
}
The client only ever contributes a reference (itemId); the action re-reads everything that determines permission from the trusted session, not from the request body. The return value is also deliberately thin — { success: true }, not item — because whatever an action returns gets serialized straight to the client, and an internal field slipped into a raw database record is now public.
itemId for a row that isn't yours. Validate the shape and re-derive identity from the session, not from anything the client sent.The lesson on Server Action fundamentals establishes the core mechanism — a Server Action is really a POST request to wherever it's defined, so a check that only lives in the UI or in request routing doesn't protect it. This lesson takes that one fact and asks what actually goes wrong when it's ignored: unauthorized access, IDOR, and data leakage through return values. It also connects to the row-level security idea from database access control — checking ownerId = session.user.id inside a Server Action is doing, in application code, the same job that a database-level row policy does at the query layer.