If you import a 'use server' function into a protected React component and pass it a typed TypeScript object, it looks and feels like a private, internal function call.
It isn't. Every exported 'use server' function in your codebase is compiled into a publicly reachable HTTP POST endpoint. Anyone with your action's identifier can invoke it directly from curl, Postman, or an automated scriptβwith any JSON payload they want, completely bypassing your UI and often bypassing the authentication checks you thought were protecting it.
If your team treats Server Actions like regular TypeScript functions instead of public REST endpoints, your production app likely has an unauthenticated mutation or an authorization bug waiting to be discovered.
The Illusion: Why Developers Write Vulnerable Actions
Look at this common pattern in a modern Next.js App Router codebase:
// app/dashboard/billing/page.tsx (Protected by Layout)
import { requireAdminSession } from '@/lib/auth';
import { issueRefund } from '@/app/actions/billing';
export default async function BillingPage() {
// β
The developer checks authentication in the Page or Layout
const session = await requireAdminSession();
return (
<form action={async () => {
'use server';
await issueRefund({ invoiceId: 'inv_9921', amountCents: 5000 });
}}>
<button type="submit">Issue $50 Refund</button>
</form>
);
}
And here is the action file it imports:
// app/actions/billing.ts
'use server';
import { db } from '@/lib/db';
interface RefundInput {
invoiceId: string;
amountCents: number;
}
// β CRITICAL VULNERABILITY: Publicly callable POST endpoint!
export async function issueRefund(input: RefundInput) {
await db.invoices.update({
where: { id: input.invoiceId },
data: { status: 'REFUNDED', refundedAmount: input.amountCents },
});
return { success: true };
}
During code review, almost everyone approves this. The reasoning seems obvious:
-
BillingPagecallsrequireAdminSession()before rendering the button. -
issueRefundrequires a strongly typedRefundInputobject. - No API route (
app/api/.../route.ts) was ever created.
Every single one of those assumptions fails at runtime.
Mistake #1: Assuming Layouts or Parent Pages Protect Server Actions
When a user navigates to /dashboard/billing, Next.js renders layout.tsx and page.tsx. If you placed your requireAdminSession() check inside layout.tsx, unauthenticated visitors get redirected before seeing the UI.
However, when a client invokes a Server Action, Next.js does not execute the parent layout.tsx rendering logic as an authorization gate before running the action. A Server Action is an independent entry point.
Furthermore, relying solely on edge middleware (middleware.ts or proxy.ts in Next.js 16) is an anti-pattern. Edge routing layers are designed for optimistic redirects and request shapingβnot as your sole data-access security boundary. Even if your middleware blocks /dashboard/*, a Server Action can be POSTed to any route in your application that accepts POST requests if the attacker sends the Next-Action header, or if matcher rules miss an unprotected path.
Notice that the request targets How an attacker triggers a "use server" function with curl
When your client bundle loads, the hashed action IDs are embedded right inside the JavaScript chunks served to the browser. Anyone inspecting the network tab or static JS files can grab the 40-character Next-Action hash and send a raw HTTP request:
curl -X POST https://your-app.com/login \
-H "Accept: text/x-component" \
-H "Content-Type: text/plain;charset=UTF-8" \
-H "Next-Action: 409a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a" \
-d '[{"invoiceId":"inv_9921","amountCents":9999999}]'
/login (a public route) while passing the Next-Action header for issueRefund. Without an explicit session check inside issueRefund itself, the database mutation executes immediately.
Mistake #2: Trusting TypeScript Types at Runtime
In our vulnerable issueRefund(input: RefundInput) example, the developer typed amountCents: number.
TypeScript types are completely erased during compilation. When an HTTP request hits a Server Action endpoint, the React Server Components (RSC) Flight deserializer parses the raw request body and passes whatever the caller sent directly into your function's arguments.
An attacker can send:
- Negative numbers (
{"amountCents": -500000}) - Unexpected types (
{"invoiceId": {"$ne": null}, "amountCents": "NaN"}) - Massive multi-megabyte strings designed to block your event loop or bloat database columns
Even worse, framework-level deserialization bugsβsuch as the critical CVE-2025-55182 (CVSS 10.0) in React Server Components and CVE-2025-66478 in Next.js, followed by the May 2026 Flight parser DoS advisoriesβproved that Server Function endpoints sit directly on the untrusted network perimeter.
Mistake #3: Accidental Export Exposure in "use server" Files
When you put 'use server' at the top of a file (app/actions/users.ts), every single export in that file becomes a public HTTP endpointβeven internal helper functions you only exported so you could unit-test them or call them from another server file!
// app/actions/users.ts
'use server';
// β You exported this only for a unit test or another server module,
// but because the file has 'use server' at the top, this is now a public endpoint!
export async function dangerouslyResetUserQuota(userId: string) {
await db.quotas.update({ where: { userId }, data: { used: 0 } });
}
If a function does not need to be invoked directly from a Client Component or HTML <form>, never export it from a 'use server' file. Move shared server logic into a plain .ts Data Access Layer (DAL) marked with import 'server-only'.
The Production Fix: Harden Every Action Boundary
To make Server Actions safe in production, treat every 'use server' function exactly like a public POST /api/* webhook. Every action must enforce three rules inside its own body:
- Authenticate inside the action (never trust layouts or middleware).
-
Validate the runtime payload with a schema (such as Zod or Valibot) using
unknownas the TypeScript parameter type. - Verify resource ownership (Authorization / IDOR check) before mutating data.
Here is the hardened, production-ready pattern:
// app/actions/billing.ts
'use server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getVerifiedSession } from '@/lib/dal/auth';
// 1. Define a strict runtime validation schema
const RefundSchema = z.object({
invoiceId: z.string().uuid(),
amountCents: z.number().int().positive().max(100_000_00),
});
// 2. Type the input as `unknown` so TypeScript forces you to parse it first
export async function issueRefund(rawInput: unknown) {
// Step A: Authenticate inside the action itself
const session = await getVerifiedSession();
if (!session || session.user.role !== 'BILLING_ADMIN') {
throw new Error('Unauthorized');
}
// Step B: Validate runtime types and constraints
const parsed = RefundSchema.safeParse(rawInput);
if (!parsed.success) {
return { ok: false, error: 'Invalid refund payload' };
}
const { invoiceId, amountCents } = parsed.data;
// Step C: Enforce tenant/object ownership (Prevent IDOR)
const updated = await db.invoices.updateMany({
where: {
id: invoiceId,
organizationId: session.user.organizationId, // Scope to caller's org!
status: 'PAID',
},
data: {
status: 'REFUNDED',
refundedAmount: amountCents,
},
});
if (updated.count === 0) {
return { ok: false, error: 'Invoice not found or not eligible' };
}
return { ok: true };
}
Read the Official Next.js Data Security and Server Actions Architecture Guide
Quick Production Audit Checklist
Run through this checklist on your repository today:
- [ ] Search your codebase for
'use server'and inspect everyexport async function. - [ ] Remove
exportfrom any helper function inside a'use server'file that shouldn't be callable from the browser, or move it to aserver-onlyData Access Layer. - [ ] Ensure every exported Server Action calls your session/authentication verification inside its own function body.
- [ ] Replace trusted TypeScript parameter types with
rawInput: unknown+ runtime schema parsing (zodorvalibot). - [ ] Scope every database
UPDATEandDELETEquery by the authenticated user'suserIdororganizationIdto prevent IDOR. - [ ] Keep
react,react-dom, andnextpatched against React Flight protocol advisories (such as CVE-2025-55182 and CVE-2026-23870) and enforce request body size limits at your reverse proxy or WAF.
The Takeaway
The ergonomics of React Server Actions are their greatest strength and their biggest security trap. By erasing the visual boundary between client code and server execution, 'use server' makes network boundaries invisible.
Never assume a layout, middleware redirect, or TypeScript interface protects a Server Action. If a file says 'use server', every exported function is a public HTTP endpointβauthenticate it, validate its payload at runtime, and authorize every database query inside the action itself.
Have you audited the 'use server' files in your production codebase recentlyβand did you find any exported functions missing an internal auth or schema check?
Sources & Further Reading
- Next.js Official Documentation: How to Think About Data Security in Next.js
- React Security Advisory: Critical Security Vulnerability in React Server Components (CVE-2025-55182)
- OWASP Top 10: A01 Broken Access Control & Insecure Direct Object References
Connect with Me
If you found this article helpful, let's connect!
- π» GitHub: johnnylemonny
Top comments (30)
Really important article, John! πΈ
use serveris convenient, but I think choosing where to use it matters a lot. It can feel like a normal function call, which makes it easy to forget that thereβs still a real security boundary there.Auth, runtime validation, and authorization all need to be handled carefully, and with the security issues weβve seen around RSC / Next.js, I think Server Actions are something we should use deliberately, not casually.
Great write-up! π
Thanks so much, @nyaomaru! π
You nailed the core issue - the DX is so smooth that it blurs the mental line between a local function call and an HTTP request. Treating
'use server'with the exact same caution as a public REST endpoint (Zod validation + auth check inside every action) is really the only safe way to use them in production.Exactly πΈ
The backend side of a Server Action really needs to be implemented with the same level of care as any normal backend API.
The DX makes it feel lightweight, but the security boundary is still very real, so itβs definitely something we canβt get too relaxed about. πΉ
100%! Couldn't agree more. Great DX shouldn't come at the expense of sound backend hygiene.
Thanks again for stopping by and sharing your thoughts! π
The quota-reset example is the trap I see most often: a helper exported from a 'use server' file "just for a test." A pattern that prevents it structurally: keep 'use server' files thin (validate the input with zod, check the session, then call into a data layer), and put internal helpers in plain modules that start with import 'server-only'. Worth adding too: Next 15's unguessable action IDs and dead-code elimination of unused actions help, but they're obscurity, not authorization. Every action still needs its own auth check.
"Obscurity, not authorization" is the exact phrase every Next.js team needs to hear regarding Next 15's action ID changes. Dead-code elimination helps with accidental unused exports, but the moment an action is referenced anywhere in the bundle, that ID is public again.
Also, complete agreement on the structural split, keeping
'use server'files strictly as thin entrypoints and pushing testable helpers into'server-only'modules makes it impossible to accidentally expose internal logic just to satisfy a unit test.Exactly. Dead-code elimination only helps until the action is referenced somewhere. Once it's in the bundle, the ID is effectively public, so the auth check has to live inside the action itself, every time.
Same trap one layer down. I took over a shop built with v0 where RLS was switched off in Supabase, and the owner's answer was "what matters is that it works". The anon key sits in the page source anyway, so the whole database was one request away.
Your curl example is what I'd show a client. A request that works from outside the UI convinces faster than any explanation.
"What matters is that it works" alongside a public Supabase
anonkey and disabled RLS is pure nightmare fuel. πAnd you're 100% right. Nothing ends a security debate with a stakeholder faster than running a single
curlcommand in a terminal and watching their production database respond without the UI.Nightmare fuel is about right. A working curl ends that kind of discussion faster than any slide deck.
Treating
'use server'like a private typed helper is such an easy trap. The curl-can-hit-it framing made it click for me β auth and authorization have to live inside the action itself, because layout guards and middleware never see that POST.Glad the
curlframing made it click! Once you picture every'use server'export as an exposedPOSTroute rather than a private helper, putting auth and schema validation right inside the action becomes second nature.The IDOR point is the one I'd underline β with
updateManyyou actually get a cheap tell at runtime: if an attacker's invoice id isn't in their org,countcomes back 0 and you can return a generic not-found instead of a 403 that confirms the record exists. Easy to lose when people refactor toward a plainupdatethat throws on missing rows.One thing I'd add to the checklist: some teams put the auth check in a wrapper and think that's enough, but with inline actions defined in JSX (
'use server'inside the form) you can't always see the call chain in review β having a lint rule that fails CI when an exported action doesn't reference your auth helper directly has caught more than one for me.Both of those are gold, Max.
The
updateManypattern is such a clean way to prevent resource enumeration - returning a generic 404 whencount === 0instead of leaking existence via a 403 (or dealing with Prisma throwingP2025on a plainupdate) is a great callout.And +100 on the lint rule! Inline
'use server'closures inside JSX are a huge blind spot during code reviews, especially because they also capture variables from the surrounding scope. Banning inline'use server'in JSX altogether and enforcing an auth helper/wrapper check in CI is honestly the safest route for teams.Mistake #3 is the scary one for me. Exporting a helper just to unit test it and accidentally making it a public endpoint is so easy to do without noticing. Also love typing the input as unknown so TypeScript forces you to validate first. Running a quick search for βuse serverβ in my projects right now.
Right? Exporting a helper for testing is pure muscle memory, which is why top-of-file
'use server'directives can be such a landmine.Glad the
unknowninput pattern resonated too. Making TypeScript work with the runtime boundary instead of hiding it saves so many headaches. Good luck with the'use server'audit! πThe curl part is what got me. Everything looks locked down in the code until you realize the action's id is sitting in the page source for anyone to POST at. So the real fix is checking auth inside the action itself and treating the layout check as cosmetic? Would love to see how you handle actions that legitimately need different permissions per caller
Spot on! Layout checks are basically just for UX/routing, while the action itself is the real security boundary. For actions with different permissions per caller, I usually wrap them in a lightweight action builder (like next-safe-action or a custom HOF) where you pass the required role/permission, or verify resource ownership right after parsing the input with Zod.
The POST-to-/login detail is the one that makes it click for me β the action doesn't care which route you hit, only the header. Layout checks as UX, action as boundary. How does the action-builder pattern hold up when actions compose, one action calling another with different permission needs? That's where I'd expect it to get messy.
You're spot on. Composing Server Actions directly gets messy fast (plus you end up running redundant auth/session checks).
The cleanest way around it is treating Server Actions like thin route handlers: they never call each other. Instead, they validate the caller once at the boundary and delegate the work to plain internal service/domain functions (marked with
'server-only') that accept the authenticateduserororgIdas an argument.The organizationId filter and updateMany count check handle the tenant boundary, but I'd add an invoice-specific amount rule beside them. A positive amount below the schema's global maximum can still exceed that invoice's remaining refundable balance. Keep that check and the state transition in one transaction, then test two simultaneous refund requests against the same invoice. That separates payload validity from business validity, which is easy to miss when the hardened example already looks complete.
Such a great distinction between payload validity and business/domain validity, Marcus.
Zod only proves the input is well-formed, not that the state transition is legal. Even better with
updateMany(or a single atomic SQLUPDATE), you can bake that balance guard directly into thewhereclause - e.g.,remainingRefundableAmount: { gte: amount }alongside adecrement, so Postgres handles the row lock atomically and concurrent refund requests can't double-spend due to a read-then-write race condition.Artur PokΕadek's point is the one I would use with a client too: a request that works from outside the UI ends the debate faster than any explanation. The action id sitting in the page source makes the risk concrete in a way a code review rarely does.
The framing that helps most here is layout checks as UX and the action as the boundary. Once a team accepts that every exported 'use server' function is a public POST endpoint, the rest follows: validate unknown input, check the session inside the action, then check ownership of the record.
Marcus Kim's addition deserves attention. Zod proves the payload is well formed, not that the refund is allowed for that invoice, so the balance check and the state change belong in one transaction, tested with two requests at once.
Aliza Ali's thin action files with helpers behind server-only would be my default on client builds. Has anyone added a lint rule that flags exports from 'use server' files that no form or component calls?
Love how you tied the whole thread together.
For catching orphaned
'use server'exports,knipin CI is the easiest win for dead exports, combined with a custom ESLint AST selector (no-restricted-syntax) that only allows exporting calls wrapped increateSafeAction()insideactions/files. That way, even if someone exports a helper purely to import it in a unit test, CI still blocks it from becoming an unintended endpoint.