skip to content

Your Next.js codebase has around forty Server Actions and every security review finds one that forgot its authorization check. How would you make that check structural instead of a per-action convention?

level: principalimportance: should knowfreq 31%

answer

  1. discipline does not scale to forty
  2. checks next to the data, not the UI
  3. export means endpoint, keep it thin
  4. deny by default in one layer
  5. make omission fail the build

basics

~20 s

Stop relying on each action to remember. Route every mutation through one data-access layer that resolves the session and enforces policy itself, keep the exported action surface thin, and make the absence of that layer detectable by lint, review, or tests rather than by audit.

solid answer

~50 s

Forty checks written by hand is forty chances to forget, so I move the decision out of the action bodies. The action becomes a thin adapter — parse the payload, call a single data-access function, revalidate — and that function resolves the session itself and denies unless a policy grants. Two supporting moves make it stick: keep the export surface of `'use server'` modules minimal, since export is what creates the endpoint, and mark the data layer with the `server-only` package so a stray client import fails the build rather than shipping. Then make the omission visible: a wrapper the actions must go through, a lint rule or dependency-boundary check that forbids reaching the database from an action module directly, and tests that call each action unauthenticated and assert a rejection. The audit stops being how you find the gap. I keep middleware for coarse redirects only — it is an optimistic check on a path, not the authoritative one.

go deeper

for a junior

Know that the authorization check belongs in server code that every mutation path runs through, and that a shared helper is safer than copying the same few lines into each action.

for a middle

Explain what an action adapter should and should not contain — parse, delegate, revalidate — and why pushing session resolution and policy into a shared data function removes a whole class of omission.

for a senior

Show how you make bypassing detectable: import boundaries, the server-only marker, a thin export surface, and tests that call every exported action unauthenticated.

for a principal

Be ready to sequence the migration by blast radius, to say what stays advisory versus blocking and for how long, and to argue why an explicit exception allowlist beats an unwritten assumption that everyone remembers.

## Diagnose the real defect "Someone forgot the check" is not a people problem; it is a design in which the safe path and the easy path differ. Any action author can write a working feature without an authorization call, the tests pass, the reviewer is looking at business logic, and the omission is invisible until an audit. The goal is to make the insecure version harder to write than the secure one. ## Move the decision below the actions The durable arrangement is a single data-access layer (DAL) that owns both session resolution and policy: ```ts import 'server-only' export async function getCurrentUser() { /* verify session cookie */ } export async function updateDocument(docId: string, patch: Patch) { const user = await getCurrentUser() if (!user) throw new Forbidden() const n = await db.doc.updateWhere({ id: docId, ownerId: user.id }, patch) if (n === 0) throw new Forbidden() } ``` The action shrinks to an adapter: ```ts 'use server' export async function saveDocument(docId: string, formData: FormData) { const patch = DocumentPatch.parse(Object.fromEntries(formData)) await updateDocument(docId, patch) revalidatePath(`/docs/${docId}`) } ``` Now the check is not something the action *does*; it is something the action *cannot avoid*, because there is no other way to reach the data. That is the whole idea: authorization lives next to the data, not next to the UI, so it is inherited by every caller — actions, route handlers, and server components alike. ## Make the layer non-bypassable A shared function only helps if nothing goes around it. - **Deny by default.** The DAL throws unless a policy explicitly permits. A new function that forgets to call the policy should fail closed, not open — construct the query builder so it requires a subject. - **Import boundaries.** Forbid action modules from importing the raw database client. A dependency-boundary tool wired into the pre-merge gate turns this from a review comment into a failing build. - **`server-only`.** Marking the DAL with the `server-only` package makes an accidental client import a build error instead of a leaked query or credential. - **Thin exports.** Every export from a `'use server'` module is a live endpoint, so a module that exports six helpers has exposed six endpoints. Keep helpers unexported or in a separate non-action module. ## Wrappers help, with a caveat A higher-order helper — `withAuth(policy, handler)` returning the action — makes the requirement syntactically visible and is a good pattern for cross-cutting concerns such as rate limiting and audit logging. Its limitation is that nothing forces its use: an action written without the wrapper still compiles and still works. So a wrapper is a convenience over the DAL, not a substitute for it. If you rely on wrappers alone, add the mechanical check that every exported action in the actions directory is produced by one. ## Make the gap testable The check you can automate is the one that survives turnover. Two cheap tests carry most of the value: 1. **Unauthenticated smoke test.** Enumerate the exported actions and assert each rejects with no session. This catches the plain omission with no per-action work. 2. **Cross-tenant test.** For each resource type, one test that user A cannot mutate user B's record. This catches the subtler case where a check exists but ties to the wrong subject. Add them to the same pre-merge gate as types and lint, so a new action without a check fails on the author's machine rather than in a quarterly review. ## Where middleware fits A path matcher is a good place to bounce anonymous traffic away from a whole section of the app early, and it improves the experience. It is not the authoritative control: it operates on path patterns, not on which action id is being dispatched, patterns drift as routes are added, and it usually cannot afford a database lookup. Next's own guidance treats it as an optimistic check. Say this in an interview and then say where the real one lives. ## Sequencing the migration With forty existing actions, do not attempt a big-bang rewrite. Land the DAL and the boundary rule first with the rule warning rather than failing. Convert the highest-blast-radius actions — anything touching money, roles, tenancy, or deletion — and add the unauthenticated smoke test immediately, since it is the cheapest broad net. Flip the boundary rule to error once the list of exceptions is short enough to read. Track the remaining exceptions as an explicit allowlist file so the residual risk is a visible artifact rather than folklore. ## The judgment to convey The principal-level answer is not "use a wrapper." It is: identify that per-call discipline does not scale, relocate the invariant to where every path must pass, make violations mechanically detectable, and stage the migration so the highest-risk surface is covered first while the enforcement is still advisory.

  • Why is a withAuth wrapper alone not sufficient?
    Because nothing forces an author to use it. An action written without the wrapper still compiles, still works, and looks fine in review. Wrappers are good for making the requirement visible and for cross-cutting concerns like rate limiting, but the invariant has to sit somewhere no code path can go around — the data-access layer — with a mechanical check that the wrapper was used.
  • How do you keep the data-access layer from being pulled into a client bundle?
    Mark it with the `server-only` package so any import from a client module is a build error rather than a runtime surprise. That converts a class of leak — database credentials or query code reaching the browser — into a failure the author sees immediately, and it pairs with an import-boundary rule that stops action modules touching the database client directly.
  • With forty actions already written, where do you start?
    Land the layer and the boundary rule in warning mode, then convert by blast radius: money, roles, tenancy, and deletion first. Add the unauthenticated smoke test across all exported actions on day one because it is the broadest cheap net, and flip the boundary rule to error once the exception list is short enough to read in one screen.

saying these in an interview costs you the question

  • Add it to the code review checklist and remind the team
  • Middleware on the route group is the enforcement point
  • One wrapper function solves it, no other change needed
  • Types can encode the requirement, so runtime checks are redundant
  • Rewrite all forty actions in one pass before shipping anything

context