skip to content

In a Next.js App Router app, a deleteInvoice Server Action is only reachable from a delete button that the page renders for admins. Explain why rendering the button conditionally is not an authorization control, and where the check has to live instead.

level: middleimportance: must knowfreq 74%

answer

  1. actions are endpoints, not callbacks
  2. the browser is not the only caller
  3. hiding UI is not a gate
  4. session read inside the body
  5. authenticate, then authorize the row

basics

~20 s

A Server Action compiles into a public POST endpoint that anyone can call directly with its generated id, so hiding the button hides nothing. Authentication and authorization must run inside the action body before it reads or writes anything.

solid answer

~50 s

Every exported Server Action is an RPC endpoint that ships with the deployment. The browser invokes it by POSTing to the current route URL with the action's generated id in a Next-specific request header, and anyone who has ever loaded a page that referenced it — or who simply replays that request — can call it again with any payload. Conditional rendering only decides what a *cooperating* client draws; it is a UX affordance, not a gate. So the action body itself has to resolve the session (in Next 15 and later via `await cookies()` or your auth library's server helper), reject unauthenticated callers, check that this specific user may delete this specific invoice, and only then touch the database. I treat the `'use server'` boundary exactly like a controller in a REST API: it is the first line of my code an attacker can reach directly.

code

typescript · 19 lines
typescript
'use server'

import { cookies } from 'next/headers'

async function getSession(sessionId: string | undefined) {
  if (!sessionId) return null
  // replace with your session store lookup
  return sessionId === 'admin-token' ? { userId: 'u1', role: 'admin' } : { userId: 'u2', role: 'member' }
}

export async function deleteInvoice(invoiceId: string) {
  const store = await cookies()
  const session = await getSession(store.get('session')?.value)

  if (!session) throw new Error('Unauthenticated')
  if (session.role !== 'admin') throw new Error('Forbidden')

  console.log('deleting', invoiceId, 'for', session.userId)
}

go deeper

for a junior

Be ready to say plainly that a Server Action runs on the server but can be called by anyone, and that whether a button is rendered has nothing to do with whether the action can run.

for a middle

Explain the mechanics: the action becomes a POST endpoint with a generated id, the client stub sends that id, and the body is the first code you control that an attacker reaches. Show the session lookup inside the action.

for a senior

Demonstrate that you separate authentication from per-record authorization, push the ownership predicate into the query, and treat a stale tab or a revoked role as the everyday case rather than an exotic attack.

for a principal

Own the tradeoff between per-action checks and a single enforced data-access layer, and explain how you keep a missing check from being possible at all — shared wrappers, a thin export surface, and review or lint enforcement rather than discipline.

## The mental model: an action is an endpoint When you mark a module or a function with `'use server'`, Next.js does not merely "run this on the server." It creates a callable endpoint. At build time each action gets a stable generated id; the client reference you import is a thin stub that POSTs to the current route URL, carrying that id in a Next-specific request header (`Next-Action`, visible in DevTools) and the arguments in the request body. The server dispatches on the id and runs your function. That means the surface area of your app is not "the pages I linked" but "every action the build emitted." Nothing about the endpoint depends on which component rendered the button, whether that component rendered at all, or whether the user is looking at your UI. ## Why conditional rendering is not a control ```tsx {session.role === 'admin' && <DeleteButton invoiceId={id} />} ``` This decides what a cooperating browser draws. An attacker is not a cooperating browser. They can: - open DevTools on any page that *did* render the button, copy the request as cURL, and replay it with a different body; - read the action id out of the client bundle or a previous response and craft the POST by hand; - keep a stale tab from when they legitimately had the role, and fire it after the role was revoked. The general rule: **anything the client can observe, the client can forge.** Role flags, hidden fields, disabled attributes, and "the button is not on the page" are all client-side facts. ## Where the check belongs Inside the action body, before any effect: ```ts 'use server' import { cookies } from 'next/headers' export async function deleteInvoice(invoiceId: string) { const store = await cookies() // async in Next 15+ const session = await getSession(store) // your auth layer if (!session) throw new Error('Unauthenticated') if (session.role !== 'admin') throw new Error('Forbidden') await db.invoice.delete(invoiceId, { tenantId: session.tenantId }) } ``` Two distinct checks are doing different jobs. **Authentication** answers "who is calling?" and must be derived from server-held state — a session cookie your server can verify — never from an argument. **Authorization** answers "may that caller do this to this row?" and usually needs the record: an admin of tenant A must not delete tenant B's invoice, so the ownership predicate belongs in the query itself rather than in a separate read-then-write. ## Common wrong answers *"Middleware already protects that route."* A `middleware.ts` matcher runs before requests and is a reasonable coarse redirect for unauthenticated traffic, but it is an optimistic check on a path pattern. It does not know which action id is being dispatched, it usually cannot do a database lookup cheaply, and matcher patterns drift. Next's own guidance is to keep the authoritative check next to the data. *"The action isn't imported anywhere anymore."* Export is what makes it reachable, not import. Dead actions left exported in a `'use server'` module are live endpoints. *"It's typed, so bad input can't get through."* TypeScript annotations are erased. The request body is whatever the caller sent. ## Failure mode this prevents The realistic incident is not a sophisticated attack: it is a role change that never propagates. A user is demoted, their open tab still holds the rendered button, and the action still runs because the only gate was rendering. Because the check now lives in the body, the very next invocation re-reads the session and fails — the control is evaluated at call time, on the server, every time. ## Practical framing for an interview Say the phrase "the `'use server'` boundary is a trust boundary." Then give the three concrete consequences: the endpoint exists whether or not the UI does; identity comes from the session, not from arguments; and the check runs on every call, so revocation is immediate. Add that you keep the check in one shared data-access layer rather than copy-pasting it into forty action bodies, so a missing check becomes structurally hard rather than a review-time catch.

  • The user's role is revoked while they still have the page open. Walk me through what happens on their next click.
    The stub still POSTs to the action endpoint, because the rendered markup is stale. The action body re-reads the session cookie on the server, resolves the current role, and rejects. That is the point of checking at call time rather than at render time: revocation takes effect on the next invocation, with no cache invalidation or forced reload needed.
  • Should the action throw, or return an error object?
    For a genuine authorization failure I throw or call `redirect()` to the login page — there is no useful message for the client, and returning a structured error tempts callers to render it. For expected, user-fixable failures such as validation, I return a serializable result object so the form can display a field-level message via the React state hook that drives it.
  • Does putting the check in a Route Handler instead change anything?
    No. A `route.ts` handler is also a public endpoint, so the same rule applies: resolve identity server-side and authorize the specific resource inside the handler. The choice between an action and a route handler is about the calling shape — form submission and revalidation versus a general HTTP API — not about who can reach it.

saying these in an interview costs you the question

  • The button only renders for admins, so it's safe
  • Middleware on that path already handles authorization
  • The action isn't exported to the client, so it's private
  • TypeScript parameter types stop bad input at runtime
  • Nobody knows the action id, so it can't be called

context