+1 (781) 556-6029

Server Functions are public endpoints: securing a React app that runs code on the server

Moving to Server Components changes where your security boundary sits, and many teams do not notice. A component that used to call fetch('/api/orders') from the browser now imports a function marked 'use server' and calls it. The call site looks local. The transport is not: the framework generates a POST endpoint, and anyone who can reach your origin can send it a request, whether or not your UI ever renders the button.

This post is an audit procedure for an existing App Router codebase. It does not require a rewrite and most of it is a morning of reading.

1. Inventory the entry points

Every export from a file with a top-level 'use server' directive, and every inline 'use server' function inside a component, is reachable from the network. Start by listing them.

grep -rn "use server" src app --include=*.ts --include=*.tsx -l

Put the result in a spreadsheet: file, exported function, what it reads or writes, and who is supposed to call it. A function with no answer in the last column is your first finding.

Also list route handlers (route.ts) and Middleware matchers. They share the same problem and are easy to forget while auditing the new surface.

2. Authorize inside the function

Hiding a button for non-admins is a UX decision, not an access control. Each Server Function must check the session and the permission itself, then check that the caller may touch the specific record.

'use server'

export async function archiveProject(input: unknown) {
  const session = await requireSession()          // throws if anonymous
  const { projectId } = archiveSchema.parse(input) // see step 3
  const project = await db.project.findUnique({ where: { id: projectId } })
  if (!project || project.orgId !== session.orgId) {
    throw new Error('Not found')
  }
  await db.project.update({ where: { id: projectId }, data: { archivedAt: new Date() } })
}

The ownership check on orgId is the line most often missing. Authenticating the user and then trusting an id from the client is a classic insecure direct object reference, and it is not made safer by TypeScript.

Do not rely on layout-level or middleware-only checks as the sole guard. Layouts do not re-render on every navigation, and a Server Function call does not pass through your page tree at all.

3. Validate input at runtime

The TypeScript signature of a Server Function describes what your client code sends. It says nothing about what a hand-written request sends. Type the parameter as unknown and parse it with a schema library such as Zod or Valibot. This also applies to FormData: read fields explicitly, do not spread the whole object into a database call.

Return only what the caller needs. A function that returns a full database row hands over every column, including ones the UI never shows.

4. Check what crosses to the client

Props passed from a Server Component to a Client Component are serialized into the page payload. Passing a whole user object because it was convenient puts the password hash column in the HTML. Fix this by selecting fields at the data layer, or by building a small data-transfer object per component.

Two guards are cheap:

  • Import the server-only package in modules that touch secrets or the database, so an accidental client import fails the build.
  • Keep secrets out of NEXT_PUBLIC_ variables (or your framework's equivalent public prefix). That prefix means "inline into the browser bundle".

React also ships experimental taint APIs. We would not make them your primary control; explicit field selection is easier to review.

5. Rate limits and side effects

A Server Function that sends email, charges a card, or runs an expensive query needs the same abuse controls as any API route: rate limiting per session and per IP, idempotency keys for payments, and logging that records the caller. If you would not ship a public endpoint without them, do not ship the function without them.

6. Patch posture is part of the audit

Framework code that deserializes client requests is a security-sensitive dependency. In December 2025 a critical remote code execution vulnerability (CVE-2025-55182) was disclosed in the React Server Components packages, affecting react-server-dom-webpack, react-server-dom-parcel, and react-server-dom-turbopack in React 19.0 through 19.2.0, with downstream fixes in Next.js and other frameworks that bundle them. Further advisories on the same packages followed. We are not going to restate exploit details here. Read the current advisories from the React team and your framework's security page, and confirm the versions you run.

What to do on your side regardless of any one CVE:

  1. Pin and record the exact react, react-dom, and framework versions in the lockfile, and know which are deployed in production, not just on main.
  2. Subscribe to GitHub security advisories for the framework repository, or enable Dependabot security updates.
  3. Have a tested path for shipping a patch-version bump in a day. If upgrades are painful enough that you cannot, that is a finding in itself, and a reason to fix the upgrade path before the next advisory arrives.
  4. Note that an app using only client-side React with no server runtime does not have this exposure; check rather than assume.

What to do with the findings

Sort them by reachability. An unauthenticated function that writes data comes first. A function that authenticates but skips the ownership check comes second. Over-broad props and missing schemas come after. Fix them as small pull requests, one function at a time, and add a test per function that calls it as the wrong user and expects a failure.

Measure the result the same way you would measure performance: list the entry points again, and show that each one has an authorization check, a schema, and a test. If that list is clean, you have evidence. If it is not, you have a worklist.

You may not need outside help for this. It is an afternoon of grep and reading for a small app. If the inventory comes back with a hundred functions and nobody knows who owns them, that is where a review pays for itself.