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 (2)
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.