DEV Community

Cover image for Your "use server" Functions Are Public HTTP Endpoints (And Middleware Won't Save You)
𝗝𝗼𝗡𝗻
𝗝𝗼𝗡𝗻

Posted on AI-assisted

Your "use server" Functions Are Public HTTP Endpoints (And Middleware Won't Save You)

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

During code review, almost everyone approves this. The reasoning seems obvious:

  1. BillingPage calls requireAdminSession() before rendering the button.
  2. issueRefund requires a strongly typed RefundInput object.
  3. No API route (app/api/.../route.ts) was ever created.

Every single one of those assumptions fails at runtime.


The Golden Rule of Server Functions: Adding 'use server' does not create a private server helper. It instructs the bundler to register an HTTP POST handler backed by the React Flight protocol and assign it a public Next-Action hash.

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.

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}]'
Enter fullscreen mode Exit fullscreen mode

Notice that the request targets /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 } });
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Authenticate inside the action (never trust layouts or middleware).
  2. Validate the runtime payload with a schema (such as Zod or Valibot) using unknown as the TypeScript parameter type.
  3. 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 };
}
Enter fullscreen mode Exit fullscreen mode

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 every export async function.
  • [ ] Remove export from any helper function inside a 'use server' file that shouldn't be callable from the browser, or move it to a server-only Data 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 (zod or valibot).
  • [ ] Scope every database UPDATE and DELETE query by the authenticated user's userId or organizationId to prevent IDOR.
  • [ ] Keep react, react-dom, and next patched 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


Connect with Me

If you found this article helpful, let's connect!

Top comments (2)

Collapse
 
ivannovazzi profile image
Ivan Annovazzi •

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

Collapse
 
johnnylemonny profile image
𝗝𝗼𝗡𝗻 •

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.