Authorization in Server Actions: Hiding the Button Is Not Protection
Next.js Server Actions are directly callable endpoints. A small policy pattern that moves the authorization check inside the action, plus how to test it.
Authorization in Server Actions: Hiding the Button Is Not Protection#
The middleware page-protection note drew a line between link-only pages and truly private areas. Server Actions are where that line gets crossed. Hiding a button in the frontend does not protect the action; the action is a directly callable endpoint, and middleware outside route matching does not see it.
Why middleware is not enough#
Middleware answers whether the caller is signed in and whether the route is private. It does not answer which invoice was requested, for which operation, and with which membership role. That split is deliberate: identity and route checks live in middleware, object and operation checks live inside the action.
| Question | Where it is answered | Example |
|---|---|---|
| Is the user signed in | Middleware | Redirect anonymous requests to login |
| Is this route private | Middleware | Require identity on /invoices matches |
| Which tenant owns this invoice | Policy inside the action | Filter by tenantId in the query |
| Which role does the caller hold on this invoice | Policy inside the action | Check memberships.some with role filter |
| Is this a read or a write | Policy inside the action | read versus write split in the function |
| Is this an admin operation | Policy inside the action | Separate admin level in the function |
Read from the bottom up, the table shows the limit. Middleware covers the first two rows and cannot cover the last four. The last four rows all depend on a request parameter. Middleware does not open the invoiceId from the request body and run an ownership query. Placing that query in middleware would add a database read to every request, split role logic across two places, and remove the single source of truth.
There are call shapes that middleware never sees. They are not edge cases, they follow from how Server Actions work as POST endpoints:
- Posting directly to the action endpoint without opening the page. No page visit is needed, the action reference and its argument are enough.
- Calling an action that has no visible button, with a parameter copied from developer tools. Absence from the DOM does not block the call.
- Sending another tenant's
invoiceIdwith your own session. The route stays the same, the parameter changes, middleware sees no difference. - Using a read-only account to call a write action. At route level both callers look identical, the difference only appears in the role check.
- Submitting a copied form from a separate client. A
multipartor JSON body is processed without page context.
The point is that middleware is a route and identity layer, not an object and operation layer. The page can look protected while the side path stays open. The fix is not a larger middleware, it is one small check placed inside the action.
Policy lives in one function#
if (ownerId !== user.id) checks scattered across actions repeat the same mistake as authorization scattered across controllers. There is one policy function, and actions call it:
// lib/actions/policy.ts export async function requireInvoiceAccess( invoiceId: string, action: 'read' | 'write', ) { const session = await auth() if (!session) throw new Error('Unauthorized') const invoice = await db.invoice.findFirst({ where: { id: invoiceId, tenantId: session.tenantId, memberships: { some: { userId: session.user.id, role: action === 'write' ? { in: ['owner', 'finance'] } : { in: ['owner', 'finance', 'viewer'] }, }, }, }, }) if (!invoice) throw new Error('Not found') return { invoice, session } }
Unauthorized and nonexistent resources produce the same error again. The read/write role split lives inside the function, not rewritten per action.
The sketch above carries the idea, the fuller version below is what stays in the codebase. The role list sits in a table, the session is read from one place, and the tenant filter is mandatory in every query:
// lib/actions/policy.ts import { cache } from 'react' import { auth } from '@/lib/auth' import { db } from '@/lib/db' export type InvoiceAction = 'read' | 'write' | 'admin' const ROLE_BY_ACTION: Record<InvoiceAction, string[]> = { read: ['owner', 'finance', 'viewer'], write: ['owner', 'finance'], admin: ['owner'], } export const getSession = cache(async () => auth()) export async function requireInvoiceAccess( invoiceId: string, action: InvoiceAction, ) { const session = await getSession() if (!session) throw new Error('Unauthorized') const invoice = await db.invoice.findFirst({ where: { id: invoiceId, tenantId: session.tenantId, memberships: { some: { userId: session.user.id, role: { in: ROLE_BY_ACTION[action] }, }, }, }, }) if (!invoice) throw new Error('Not found') return { invoice, session } }
Four decisions sit in one block. The role hierarchy is readable in ROLE_BY_ACTION: read is open to three roles, write narrows to two, admin narrows to one. Tenant scope is part of the query through the tenantId filter, not an afterthought. The read write admin split is not repeated with string comparisons inside each action. The error contract is two lines: Unauthorized when there is no session, Not found when the invoice is missing or the membership does not match.
The error contract deserves attention. Forbidden and missing must return the same error. Otherwise an attacker can compare error messages to learn which invoiceId values are real. That signal looks small, yet it confirms object existence for anyone scanning IDs. The principle matches object-level checks in API authorization: missing and forbidden share one response.
The single entry point follows from the same idea. Every action calls requireInvoiceAccess, none writes its own role query. When a role is added or an operation changes level, one table is updated. During review there is one place to inspect: the policy file, plus the question whether any action skips the call.
Actions stay thin#
// app/invoices/actions.ts 'use server' export async function exportInvoicePdf(invoiceId: string) { const { invoice } = await requireInvoiceAccess(invoiceId, 'read') return renderPdf(invoice) }
PDF export is chosen deliberately: it is the classic side path that looks protected because the detail page is protected while the export action is not.
Once the pattern is set, each action repeats the same skeleton. The only difference between read, write, and admin is the second argument:
// app/invoices/actions.ts 'use server' import { requireInvoiceAccess } from '@/lib/actions/policy' export async function exportInvoicePdf(invoiceId: string) { const { invoice } = await requireInvoiceAccess(invoiceId, 'read') return renderPdf(invoice) }
// app/invoices/actions.ts 'use server' import { z } from 'zod' import { db } from '@/lib/db' import { requireInvoiceAccess } from '@/lib/actions/policy' const UpdateInput = z.object({ note: z.string().max(500), }) export async function updateInvoice(invoiceId: string, raw: unknown) { const { invoice } = await requireInvoiceAccess(invoiceId, 'write') const input = UpdateInput.parse(raw) return db.invoice.update({ where: { id: invoice.id }, data: { note: input.note }, }) }
// app/invoices/actions.ts 'use server' import { requireInvoiceAccess } from '@/lib/actions/policy' import { queueWebhookRetry } from '@/lib/billing/queue' export async function retryWebhook(invoiceId: string) { const { invoice } = await requireInvoiceAccess(invoiceId, 'admin') return queueWebhookRetry(invoice.id) }
The shared trait is order: the policy line comes first, business logic follows. updateInvoice validates input with zod, but validation never replaces authorization. The sequence is fixed: access first, then parsing, then the write. retryWebhook is open to owner only, and the server enforces that split even when the interface hides the button from everyone else.
Each function stays between 5 and 10 lines. Short length is not style, it keeps review simple. A thin action reads in one glance. An action without the policy call stands out immediately in the file.
The hidden-button anti-pattern#
The fragment below appears in real projects. Users without the right role never see the button, and the team assumes the action is protected:
// BAD: hides the interface, adds no server check export function InvoiceToolbar({ role, id }: { role: string; id: string }) { if (role !== 'owner') return null return <button onClick={() => retryWebhook(id)}>Retry</button> }
The fault is not a missing condition, it is the wrong layer. Skipping the render concerns the DOM, not the endpoint. While the action stays callable over POST, a viewer can pass a valid invoiceId in the request body and run the same function. An invisible button never blocks a crafted request.
The lesson is direct: conditional render is a usability choice, not a security choice. The security choice sits on the first line of the action function. Conditional render can stay, but it never counts alone. Each action without a policy call is an open door behind a hidden button.
Test the action, not the button#
The test targets the direct invocation, not the button's visibility. The same action is called with two different memberships; the second user's call must return an error, not data. The error matching "Not found" shows object existence does not leak.
The test below sets up two memberships: a finance member with access to the invoice, and an outsider from another tenant or with no relation to the invoice. No interface is opened, the action function is called directly:
// app/invoices/actions.test.ts import { describe, it, expect, vi } from 'vitest' import { exportInvoicePdf } from './actions' vi.mock('@/lib/actions/policy', async (importOriginal) => { const mod = await importOriginal<typeof import('@/lib/actions/policy')>() return { ...mod } }) describe('exportInvoicePdf', () => { it('returns a PDF for a member with access', async () => { const pdf = await exportInvoicePdfAs('finance-user', 'inv_123') expect(pdf).toBeDefined() }) it('returns an error, not data, for an unrelated user', async () => { await expect( exportInvoicePdfAs('outsider-user', 'inv_123'), ).rejects.toThrow('Not found') }) it('does not reveal existence', async () => { const missing = await captureError(() => exportInvoicePdfAs('outsider-user', 'inv_missing'), ) const forbidden = await captureError(() => exportInvoicePdfAs('outsider-user', 'inv_123'), ) expect(forbidden.message).toBe(missing.message) }) })
The helper impersonates a session and runs against a real or isolated test database. The call path matters: no component renders, no button is clicked, exportInvoicePdf is invoked as a function.
The assertion list is short, and each item closes a distinct hole:
- An authorized call returns data, an unauthorized call returns no data.
- The unauthorized error matches the
Not foundtext, notUnauthorizedand not a custom forbidden message. - A missing record and a forbidden record return the same error, the two cases stay indistinguishable.
- The same matrix repeats for write and admin actions: a
viewercan read but cannot write,financecan write but cannot start a webhook retry. - A call with a foreign
invoiceIdreturns the same error.
Interface tests come after this list passes. An interface test proves the button is hidden, this test proves the endpoint is closed. They answer different questions.
What to log per call#
An authorization check should not pass silently. The log line helps both incident review and detection of wrong role assignments. Three facts belong on one line for each action call: who called, which object was requested, and what the policy decided.
// lib/actions/audit.ts export function logInvoiceAccess(args: { userId: string tenantId: string invoiceId: string decision: 'allow-read' | 'allow-write' | 'allow-admin' | 'deny' }) { console.log( JSON.stringify({ event: 'invoice.access', ...args, }), ) }
Without the decision field the log is only usage telemetry, not an audit record. Failed attempts log deny with the attempted invoiceId and the caller. Many deny entries from one user in a short window suggest ID scanning.
Sensitive fields such as invoice totals or customer email addresses do not belong in the log. Object identity plus the decision is enough. The goal is not to copy the data, it is to keep access traceable.
Four common mistakes#
1. Trusting client-supplied IDs#
An action argument is user input. The invoiceId value arrives from the address bar, a form field, or the request body. The policy accepts that value only through the ownership filter. A query without the filter leaves the door open:
// BAD: no tenant or membership filter const invoice = await db.invoice.findFirst({ where: { id: invoiceId }, })
The correct query always carries three conditions together: id, tenantId, and memberships.some. When one of the three is missing, the action does not ship until the query is fixed.
2. Missing the tenant filter on included relations#
The main invoice query can be correct while relations pulled through include escape the scope. Invoice lines, payment attempts, or webhook records may live in separate tables, and each must be read in tenant context:
// BAD: parent is filtered, relation is not const invoice = await db.invoice.findFirst({ where: { id: invoiceId, tenantId: session.tenantId, }, include: { webhookAttempts: true }, })
The relation looks harmless on its own, but the scope widens when the action returns those rows. Relations are either read through a separate filtered query or the membership condition of the parent is carried onto the relation. The rule is plain: each row returned to the user must pass the same policy filter.
3. Leaking existence through distinct errors#
Separate messages for missing and forbidden are a frequent oversight:
// BAD: the two cases stay distinguishable if (!invoice) throw new Error('Invoice does not exist') if (!canAccess) throw new Error('Access denied for this invoice')
The first message states the record is absent, the second states the record exists but access was refused. An attacker can use that difference during ID scans. The single-message rule exists for this reason: missing and forbidden share one Not found response. Error strings are read one by one during review, no outlier message remains.
4. Calling auth() per action without caching#
When each action calls auth() on its own, the same request resolves the session repeatedly. A single read wrapped in React cache() removes that repetition:
// lib/actions/policy.ts import { cache } from 'react' import { auth } from '@/lib/auth' export const getSession = cache(async () => auth())
Policy and actions use getSession() instead of auth(). Behavior stays the same, only repeated work is gone. More importantly the session source becomes one function: when session handling changes, one function is updated instead of every action.
What this pattern does not cover#
This policy closes object and operation authorization. It does not take over other layers. Knowing the boundary keeps the pattern from looking stronger than it is.
Rate limiting sits outside this pattern. An authorized user can call the endpoint dozens of times per second, and the policy will pass each time. Abuse by volume is about count, not identity. A separate layer restricts request counts where needed.
Business-logic abuse is separate too. An authorized finance member can update valid invoices in sequence or change amounts outside sensible ranges. The policy states the role may act, it never states the act is commercially sound. Amount ranges, approval flows, and dual control belong in the business-logic layer.
Client-side trust is not solved here either. Hidden fields, disabled buttons, or read-only forms remain interface choices until the server validates them again. The server treats each value from the client as unvalidated input. That is why zod parsing runs after the policy, not before.
Finally the pattern does not close every data leak. Sensitive fields written to logs, internal detail added to error messages, or extra data embedded in the PDF are separate reviews. The policy guards the door, arrangement inside the room is separate work.
Short result#
One question when writing a Server Action: was this function written for the user who sees the button, or for the user who calls it? The safe answer is always the second. Policy in the function, thin actions, tests against direct calls.
When a new action is added the order stays fixed: write the operation level into the policy table, call it in one line inside the action, run the test with two memberships, add the audit log. The button condition comes last and never counts as the only protection.
What do you think?
React to show your appreciation