Diego Betto
Photo by Sasun Bughdaryan on Unsplash

Diego Betto · October 9, 2026 · 17 min di lettura

Securing Server Actions and React Server Components: data leaks, validation, and taint APIs

How to secure Server Actions and React Server Components: implicit endpoints, input validation, authorization, environment variables, taint APIs, and recent CVEs.

Condividi:XLinkedInFacebookWhatsApp

With React 19 and full-stack architectures, the boundary between client and server has shrunk to a single line of text: 'use client' on one side, 'use server' on the other. It’s convenient — a component reads straight from the database, a form calls a server function without anyone writing an API — but that convenience is exactly what moves the problem. The boundary is still there; you just can’t see it anymore. And what you can’t see, you usually don’t protect.

This article looks at the two main risks in applications built with React Server Components: sensitive data reaching the client without anyone deciding it should and Server Actions that are public endpoints without looking like one. The examples use the Next.js App Router, the most widely used RSC framework, but the principles apply to any implementation.

The mental model: two flows crossing the network

An RSC app has two channels between server and browser, and both are serialization over HTTP:

Direction What travels Main risk
Server → client Client Component props, return values, closures Data leaks: you send more than needed
Client → server Server Action arguments, FormData Untrusted input on a public endpoint

Everything a Server Component passes to a Client Component ends up in the RSC payload: you can read it in the browser’s Network tab even if the component never renders it. And everything a Server Action receives comes from a POST request that anyone can craft by hand, with curl, without going through your UI.

Keeping those two sentences in mind already solves half the problems.

Risk 1: data that “slips” to the client

The most common wrong pattern

// app/profile/[slug]/page.tsx — Server Component
import { db } from "@/lib/db";
import ProfileCard from "./profile-card";

export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const user = await db.user.findUnique({ where: { slug } });

  // ❌ the whole record crosses the boundary: passwordHash, email,
  // stripeCustomerId, internal flags... all in the RSC payload
  return <ProfileCard user={user} />;
}
// app/profile/[slug]/profile-card.tsx
"use client";

export default function ProfileCard({ user }: { user: User }) {
  return <h2>{user.name}</h2>; // uses only the name, receives everything
}

ProfileCard only shows the name, but the browser receives the entire object. No error, no warning: the code works, the tests pass, and the password hash sits right there in the response body.

The fix is structural, not cosmetic: Client Components should declare minimal props, and data should be trimmed before it reaches the render.

// profile-card.tsx
"use client";

type ProfileCardProps = { name: string; avatarUrl: string | null };

export default function ProfileCard({ name, avatarUrl }: ProfileCardProps) {
  // ...
}

A narrow props type is already a defense: TypeScript won’t stop you from passing a bigger object (structural typing accepts it), but it forces you to write name={user.name} instead of user={user}, and that bit of friction is exactly what you want.

A Data Access Layer with DTOs

For new projects, the Next.js documentation recommends centralizing data access in a Data Access Layer (DAL): a module that only runs on the server, performs authorization checks, and returns DTOs — objects containing only the fields the UI is allowed to see.

// data/users.ts
import "server-only";
import { cache } from "react";
import { db } from "@/lib/db";
import { getCurrentUser } from "./auth";

export type PublicProfileDTO = {
  name: string;
  avatarUrl: string | null;
  email: string | null; // only visible to the user themselves or to admins
};

export const getPublicProfile = cache(async (slug: string): Promise<PublicProfileDTO | null> => {
  const [user, viewer] = await Promise.all([
    db.user.findUnique({ where: { slug } }),
    getCurrentUser(),
  ]);
  if (!user) return null;

  const canSeeEmail = viewer?.id === user.id || viewer?.role === "admin";

  return {
    name: user.name,
    avatarUrl: user.avatarUrl,
    email: canSeeEmail ? user.email : null,
  };
});

Three details that matter:

  • import "server-only" fails the build if the module is imported, even indirectly, from a 'use client' file. It’s an assert on your import graph.
  • The DTO is built field by field, not with a spread or an omit. If someone adds a twoFactorSecret column to the table tomorrow, omit(user, ["passwordHash"]) would let it through; an explicit list won’t.
  • cache() deduplicates calls within the same render: you can call getCurrentUser() in ten components without ten queries, and without the temptation to pass the current user down as a prop from component to component (which is the fastest way to get it into a Client Component).

Environment variables

Environment variables are the trickiest case, because the bundler treats them in two different ways:

  • In Next.js, variables prefixed with NEXT_PUBLIC_ are inlined into the JavaScript bundle at build time. They’re public by definition, even if you only use them in a Server Component. A NEXT_PUBLIC_STRIPE_SECRET_KEY is an incident, not a configuration.
  • The others are only available on the server — but a Server Component can still read them and pass them as a prop, and at that point they travel in the payload like any other string.
// ❌ the secret crosses the boundary as a regular prop
export default async function Page() {
  return <MapWidget apiKey={process.env.MAPS_SERVER_KEY} />;
}

Two practical rules: only the DAL reads process.env (so an audit boils down to searching for process.env outside data/), and secrets are never passed, they’re used on the server — if the client needs to call an external service, the server proxies the call or issues a short-lived, narrowly scoped token.

Taint APIs: a safety net

React provides two APIs to mark values that must never reach a Client Component. If they do, rendering throws instead of serializing them:

// data/env.ts
import "server-only";
import { experimental_taintObjectReference, experimental_taintUniqueValue } from "react";

// the entire process.env object can't be passed to a Client Component
experimental_taintObjectReference(
  "Do not pass the entire process.env to the client.",
  process.env,
);

// a specific value: (message, lifetime, value)
experimental_taintUniqueValue(
  "The payment provider key must never reach the client.",
  process,
  process.env.PAYMENT_SECRET_KEY!,
);
// data/users.ts
export async function getUserRecord(id: string) {
  const user = await db.user.findUnique({ where: { id } });
  if (user) {
    experimental_taintObjectReference("Use getPublicProfile() for display data.", user);
    experimental_taintUniqueValue("Password hash", user, user.passwordHash);
  }
  return user;
}

The second argument to taintUniqueValue is the lifetime: the value stays tainted as long as that object is alive. For process-wide secrets you use process; for a field on a record, the record itself.

In Next.js you enable them with experimental.taint in next.config:

// next.config.mjs
export default {
  experimental: {
    taint: true,
  },
};

⚠ What tainting doesn't do

The taint APIs are still experimental (stable React doesn’t export them) and only protect that exact reference or value. A copy of the object ({ ...user }) isn’t tainted; neither is a derived value (secret.slice(0, 10), secret.toUpperCase()). They don’t replace DTOs and minimal props: they’re a seatbelt for when someone, six months from now, writes user={user} in a hurry.

Closures in inline Server Actions

A Server Action defined inside a Server Component can capture variables from its scope:

export default async function Page() {
  const internalPrice = await getInternalCost(); // confidential data

  async function applyDiscount() {
    "use server";
    // uses internalPrice...
  }

  return <form action={applyDiscount}>...</form>;
}

To make the closure work, the captured variables are sent to the client and back to the server on every invocation. Next.js encrypts them automatically with a key generated per build, but its own documentation advises against relying on encryption alone. Simple rule: action closures capture identifiers (an id, a version), never confidential data; read the data again inside the action.

If you self-host across multiple instances, set NEXT_SERVER_ACTIONS_ENCRYPTION_KEY (a base64 AES key, for example from openssl rand -base64 32) so every instance shares the same key, and treat it like any other secret: out of the repository, with rotation.

Risk 2: Server Actions are public endpoints

This is the sentence to pin above your desk: every function exported from a 'use server' file and used by the app is an HTTP POST endpoint anyone can reach. There’s no router.post() to remind you, no file under api/, but the endpoint exists.

Next.js mitigates some of this: action IDs are non-deterministic and regenerated between builds, and unused actions are stripped from the bundle (no ID, no endpoint). But an action used by any Client Component has its ID in the JavaScript the browser downloads, and from there a hand-crafted request is trivial.

Authentication: inside the action, always

The classic mistake is assuming that protecting the page also protects the actions it contains:

// app/admin/page.tsx
export default async function AdminPage() {
  const session = await auth();
  if (session?.user.role !== "admin") redirect("/login"); // protects the UI...

  return <DeleteAllButton />; // ...not the action the button calls
}
// app/admin/actions.ts
"use server";

export async function deleteAllRecords() {
  // ❌ no check: anyone who knows the action ID can call it
  await db.record.deleteMany();
}

The redirect decides which UI to render; the action is a separate entry point. The same goes for middleware (renamed proxy.ts in Next.js 16): it’s a fine place for redirects and headers, not the only place for authorization. CVE-2025-29927 in 2025, which let attackers skip middleware entirely with a crafted header, is the clearest reminder of why the check belongs next to the data.

Authorization: authenticated doesn’t mean authorized

Checking that the user is logged in isn’t enough. The most common issue in actions is IDOR (Insecure Direct Object Reference): the action receives an ID and uses it without asking whether this user may touch that resource.

"use server";

export async function deletePost(postId: string) {
  const session = await auth();
  if (!session) throw new Error("Unauthorized");

  // ❌ any logged-in user can delete any post
  await db.post.delete({ where: { id: postId } });
}

The ID comes from the client: an attacker swaps it for someone else’s post. The ownership check has to happen on the server, ideally in the same query:

// ✅ only delete if the post belongs to the user
const { count } = await db.post.deleteMany({
  where: { id: postId, authorId: session.user.id },
});
if (count === 0) throw new Error("Forbidden");

Validation: TypeScript types don’t exist at runtime

This signature gives a false sense of safety:

export async function updateQuantity(itemId: string, quantity: number) { ... }

Server Action arguments are deserialized from an HTTP request. TypeScript isn’t there at runtime: quantity can arrive as -5, 1e308, a string, a nested object. Treat every action like a public API handler, with explicit validation. With Zod:

// app/cart/actions.ts
"use server";

import { z } from "zod";
import { auth } from "@/lib/auth";
import { updateCartItem } from "@/data/cart";

const UpdateQuantitySchema = z.object({
  itemId: z.uuid(),
  quantity: z.number().int().min(1).max(99),
});

type ActionResult = { ok: true } | { ok: false; error: string };

export async function updateQuantity(input: unknown): Promise<ActionResult> {
  const session = await auth();
  if (!session) return { ok: false, error: "Please sign in." };

  const parsed = UpdateQuantitySchema.safeParse(input);
  if (!parsed.success) return { ok: false, error: "Invalid data." };

  await updateCartItem(session.user.id, parsed.data.itemId, parsed.data.quantity);
  return { ok: true };
}

Note the input: unknown signature: it’s honest. Declaring (itemId: string, quantity: number) describes what you hope to receive; unknown forces you to prove it.

With forms and useActionState the pattern is identical: you validate the FormData instead of an object.

const ContactSchema = z.object({
  email: z.email(),
  message: z.string().trim().min(10).max(5000),
});

export async function sendMessage(_prev: ActionResult, formData: FormData): Promise<ActionResult> {
  const parsed = ContactSchema.safeParse(Object.fromEntries(formData));
  if (!parsed.success) return { ok: false, error: "Please check the form fields." };
  // ...
}

💡 Mass assignment

Never pass validated input straight to the ORM with a spread (data: { ...input }): if the schema isn’t strict, extra fields like role: "admin" or credits: 99999 could make it all the way to the database. Zod’s z.object strips unknown keys by default, but the most robust defense is explicitly listing the fields the action is allowed to write.

Return values and errors

An action’s return value is also serialized to the client, so the same DTO rules apply: return { ok: true } or only the updated fields, not the record your ORM hands back after an update.

For errors, distinguish between expected errors (validation, permissions), returned as values with a user-facing message, and unexpected exceptions, which you let throw: in production, React and Next.js don’t send the original message of an error thrown on the server to the client, only an identifier (digest) you can correlate with your logs. A catch that does return { error: e.message } defeats that protection and can leak query details, paths, or configuration.

CSRF and origins

Server Actions only accept POST, and Next.js compares the Origin header with Host (or X-Forwarded-Host): if they don’t match, the request is rejected. Together with the SameSite=Lax cookie default in modern browsers, that covers most CSRF scenarios.

The tricky part is reverse proxies: if the app is served behind a different domain from the one the server sees, the right fix is to declare the allowed origins, not to disable the check:

// next.config.mjs
export default {
  experimental: {
    serverActions: {
      allowedOrigins: ["app.example.com", "*.example.com"],
    },
  },
};

Outside Next.js, check what your framework does: in January 2026 React Router and Remix fixed a CSRF vulnerability in server-side actions (CVE-2026-22030, fixed in react-router 7.12.0).

Rate limiting

An action that sends email, creates records, or calls a paid API is as much a target for automated abuse as a REST endpoint. Apply rate limiting per user (or per IP for public actions) inside the DAL or a shared helper, rather than relying on “there’s only one button”.

The protocol itself: updating is a security measure

Everything so far concerns your code. But between late 2025 and 2026, the RSC protocol itself (the “Flight” format payloads and arguments travel in) was hit by a series of vulnerabilities in react-server-dom-webpack, react-server-dom-parcel, and react-server-dom-turbopack:

CVE Date Type
CVE-2025-55182 December 2025 Remote code execution (CVSS 10.0)
CVE-2026-23864 January 2026 Denial of service
CVE-2026-23869 April 2026 Denial of service (CPU exhaustion)
CVE-2026-23870 May 2026 Denial of service (memory/CPU)

The first one, nicknamed “React2Shell”, let an unauthenticated attacker run code on the server with a single request to a Server Function endpoint — without the application defining any vulnerable action: it was enough for the framework to expose the protocol. The later ones are DoS issues, with an important catch: each patch fixed the previous one, so anyone who updated once and then stopped following advisories stayed exposed. The fixes for CVE-2026-23870 shipped in 19.0.6, 19.1.7, and 19.2.6 (May 6, 2026): treat those as the bare minimum, not the target. At the time of writing, the latest release on each line is 19.0.8, 19.1.9, and 19.2.8 (July 21, 2026), which also include fixes to FormData handling in Server Actions, plus 19.3.0 (September 9, 2026). Stay on the latest patch of your line and check the releases page and advisories every time you update your framework.

⚠ Check the dependency tree, not just package.json

The react-server-dom-* packages are often transitive dependencies or vendored by the framework (Next.js ships its own copy). Bumping react in package.json isn’t enough: update the framework and check the version actually resolved with npm ls react-server-dom-webpack (or the equivalent for your bundler). The supply chain hardening techniques — lockfiles, automated audits, Dependabot/Renovate — aren’t optional here.

Audit checklist

If you need to review an RSC project, these are the things to check file by file:

  1. 'use client' files: are props minimal and typed? Does anything receive a whole record (user, order, session)?
  2. 'use server' files: does every exported action authenticate the user, check authorization on the resource, validate arguments with a schema, and return only what’s needed?
  3. Data access: are the database and process.env only used inside a DAL marked with server-only?
  4. NEXT_PUBLIC_* variables: do they only contain values you’d publish on GitHub?
  5. Closures: do inline actions only capture identifiers, not confidential data?
  6. Errors: does any catch return error.message or a stack trace to the client?
  7. Dynamic segments ([id], [slug]): are params validated like any other input?
  8. Middleware/proxy.ts: is it used as the only access control anywhere?
  9. Dependencies: do the resolved versions of react-server-dom-* and the framework include the patches from recent advisories?
  10. Safety net: is experimental.taint on, and are sensitive records tainted in the DAL?

FAQ

❓ Are Server Actions secure by default?

No, they’re convenient by default. The framework handles transport, closure encryption, and basic CSRF protection, but every action is still a public endpoint: authentication, authorization, validation, and filtering returned data are your responsibility, just like with any API.

❓ If no Client Component imports a function, is it reachable?

In Next.js, unused actions are removed at build time and never get an ID, so they aren’t reachable. But that’s a framework implementation detail, not a guarantee of the model: write every function in a 'use server' file as if it were public, and keep confidential logic in server-only modules without the directive.

❓ Can I use the taint APIs in production?

They’re available in React’s experimental builds; Next.js exposes them via experimental.taint. Weigh the risk as you would for any experimental flag. Even once they’re stable, they remain a second line of defense: a copy or a transformation of the value bypasses them.

❓ Is TypeScript enough to validate action arguments?

No. Types disappear after compilation and arguments come from an HTTP request anyone can modify. You need runtime validation (Zod, Valibot, ArkType, or manual checks) at the start of every action.

❓ Is Next.js middleware enough to protect actions?

No. Middleware (proxy.ts since Next.js 16) is useful for redirects and quick checks, but it shouldn’t be the only authorization point: checks need to be repeated inside the action or the DAL, next to the data access.

Conclusion

React Server Components and Server Actions don’t introduce new categories of vulnerabilities: data leaks, IDOR, unvalidated input, and CSRF are as old as the web. What changes is that the boundary where we usually look for them — the controller, the API route — has become implicit. The strategy that works is making it explicit again: a server-only DAL that’s the only thing touching data and secrets, DTOs built field by field, thin actions that authenticate, authorize, and validate like any public handler, and a framework kept up to date with the same care you give your own code.

For the basics on directives and components, start with the article on React Server Components; on the browser side, a Content Security Policy completes the picture.

References

Condividi:XLinkedInFacebookWhatsApp
Diego Betto

Written by

Diego Betto

Co-Founder & CTO at PAPION. Senior full-stack engineer specializing in React, TypeScript, Node.js, and application security.