Server Actions and Flight Endpoints: Auditing RSC Trust Boundaries After the Authless RCE Report

Server Actions and Flight Endpoints: Auditing RSC Trust Boundaries After the Authless RCE Report

pr0h0••23 min read•coding
AI Usage (91%)

A report dated 2026-11-09 landed in my feed with a headline claiming a critical React Server Components flaw lets attackers execute code remotely without authentication — no CVE, no version range, no exploit detail, credited to CyberSecurityNews. This post covers the part of that claim you can act on regardless of how it resolves: how RSC Flight payloads and Server Action endpoints travel the wire, why dispatch happens before your authorization code runs, and how to audit and harden that trust boundary in your own App Router deployment.

You can't write a patch advisory from a headline, and I won't pretend to. What you can do is work the part that doesn't depend on the report being accurate: the shape of the attack surface every React Server Components deployment puts on the public internet, whether this particular chain turns out to be real, misreported, or already patched.

My position up front: the load-bearing word in that headline is "authless," not "RCE." Every App Router deployment ships Server Action endpoints that are reachable without a session, addressed by an opaque ID, and dispatched before any line of your code runs. Most teams cannot enumerate them. Fewer have a threat model that includes them. That problem is durable, and it's auditable today no matter how the report resolves.

On this page

What the Authless RCE Report Actually Establishes

I've watched enough "critical RCE in $FRAMEWORK" cycles to know how the telephone game runs between a researcher's write-up, a news rewrite, and a Slack channel. So here's the evidence I actually have.

Confirmed, Unverified, and Unknown in the Public Report

StatusDetail
Confirmed (from the feed item)Publisher is CyberSecurityNews; published 2026-11-09; the headline claims remote code execution in React Server Components without authentication; the piece is framed as "critical."
UnverifiedThe exploit chain itself. Whether deserialization is involved at all. Whether it affects React itself, a bundler adapter (react-server-dom-webpack / -turbopack / -parcel), or framework glue such as Next.js. Whether it requires a crafted Flight payload, a crafted action ID, or something else entirely.
Unknown to me at time of writingCVE identifier, affected version ranges, CVSS vector, whether a fix has shipped, and whether exploitation in the wild has been observed. I'm not going to invent any of these.

A headline is not an advisory. If you're triaging right now, check primary sources in this order: the React blog, the Next.js GitHub security advisories page, then the GitHub Advisory Database entry any real CVE will produce. All three are linked in Further Reading rather than me guessing at identifiers.

Why "Authless" Is the Load-Bearing Word in the Headline

An authenticated RCE in a framework is bad. An unauthenticated one is a different category of event, because of who ends up exposed.

Server Action endpoints exist on login pages. They exist on marketing pages that import a newsletter signup client component. They exist on the / route of an app whose entire auth story is "middleware redirects to /login for /dashboard/:path*." The set of deployed apps with at least one reachable Server Action endpoint is very close to the set of deployed apps, full stop.

If the claim holds, the exposed population isn't "apps with a vulnerable authenticated feature." It's "apps." That's why I'd expect — inference, not fact — the severity framing to survive contact with the details even if the mechanics turn out narrower than the headline implies.

What I Could Not Verify, and How to Check the Primary Advisory Yourself

I couldn't reproduce the reported chain, because I don't know what it is. What I can do — and what the rest of this post does — is walk the request path any such chain would have to traverse, and hand you commands to check your own deployment's exposure to the class of bug the headline describes.

⚠️

Don't act on the headline alone, in either direction. "It's probably not exploitable" and "patch everything now" are both guesses without the advisory. Read the version ranges from the primary source, then compare against npm ls react react-dom next in the app you actually ship.

How RSC Flight Payloads Cross the Wire

To reason about whether a Flight endpoint can be abused, you need to know what's on the wire. None of this is speculation — you can see it in any App Router app with DevTools open, and you can read it in React's source.

Flight Is a Serialization Format, Not a Documented API

Flight is the wire format React uses to stream a server-rendered component tree to the client, and to send arguments to the server for Server Actions and Server Functions. It lives in react-server-dom-webpack (plus the Turbopack and Parcel equivalents) and it is deliberately not a stable public API. The format has shifted between React 19 minor releases — new type tags appear, row semantics get adjusted. Anything you build against the raw bytes is version-coupled.

Practical consequence for auditing: you can't write one parser and expect it to describe every deployment. Read the parser for the version you ship.

Flight Row Format, Chunk References, and $ Type Tags

A Flight response is a stream of newline-delimited rows. Each row starts with a hexadecimal chunk ID, then a colon (for a model row) or a type letter, then a payload. A trimmed example from a page that renders a server component with a client child:

0:{"child":"$@1"}
1:["$","main",null,{"children":[["$","h1",null,{"children":"Invoices"}],["$","$L2",null,{"id":"42"}]]}]
2:I["/_next/static/chunks/app-invoices.js","default",["/_next/static/chunks/app-invoices.js","default"]]

Left to right:

  • 0: is a model row. "$@1" is a reference to chunk 1 — a promise-shaped reference, resolved when that chunk arrives. That's how streaming works: the shell goes out before its children exist.
  • 1: is an array where "$" is the element tag, followed by type, key, and props. "$L2" is a lazy element pointing at chunk 2 — a client component reference, defined by the 2:I[...] row.
  • 2:I is a module row: the bundler's manifest for a client reference, mapping module path and export name.

The $ prefix escapes into a tagged value space. $$ is a literal dollar sign, $@ a chunk reference, $L a lazy element, $F a server reference, $D a date. The set has grown; in the versions I've looked at there are also tags for Map, Set, FormData, ArrayBuffer views, undefined, NaN, and Infinity, plus an error row type for streamed errors.

I'm deliberately not listing every tag with confidence, because I'd be guessing at the edges and the letters differ by version. Open ReactFlightClient.js in the React repo at the tag you ship — the switch in the string parser is the authoritative list for that version.

Where createFromFetch and createFromNodeStream Sit in the Request Path

The client entry points are createFromFetch (browser, wraps a Response), createFromReadableStream, and — server-side, for nested RSC — createFromNodeStream. Each hands the stream to the same deserializer, which walks rows, resolves chunk references as they arrive, and reconstructs a React tree plus any server references.

The structural fact that matters: deserialization is what produces the callable things. An action isn't invoked by name lookup in a routing table the way an Express handler is. The framework parses the payload, resolves the reference, then dispatches. Parsing is a precondition for dispatch, not something that happens after it.

Why the Deserializer Runs Before Your Route Handler Does

In Next.js, the Server Action request path runs through action-handler.ts in the server package. It reads the Next-Action header, picks the body codec (multipart when FormData or files are involved, plain text otherwise), calls decodeReply from react-server-dom-webpack/server, looks up the action in the server reference manifest, and then invokes your function with the decoded arguments.

So the call graph is: network bytes → Flight decode → manifest lookup → your async function. Every if (!session) check you write sits at the far end of that pipeline. If a bug exists in the decode step, your authorization code is not a mitigation — it hasn't run yet. That's the precise sense in which a framework-level deserialization flaw would be pre-auth: not because your app forgot to check, but because the check is downstream of the vulnerable code.

Server Actions Are a Named POST Endpoint You Never Inventoried

Take one operational idea from this post: a Server Action is an RPC method with a URL. It just isn't in your app/api directory, so your inventory probably doesn't include it.

Action IDs, the Next-Action Header, and the Server Action Manifest

When you write "use server" and export a function, the bundler assigns it a stable, opaque identifier at build time — in Next.js, a hash derived from module path and export name. The client bundle gets a createServerReference(id, callServer, ...) call for each one. The server build gets a manifest mapping ID → module export. In Next.js builds that manifest lands under .next/server/ (server-reference-manifest.json, plus a .js variant in recent versions — check your own .next tree, the path has moved before).

When a client component calls the reference, callServer POSTs to the current route with Next-Action: <id> and a Flight-encoded body from encodeReply.

How Arguments Are Reconstructed on the Server

For simple JSON-shaped arguments, the reply encoding looks like JSON in a one-element array. Here's a captured request from a lab app, trimmed:

replay a captured action, trimmed
curl -i 'http://localhost:3000/dashboard' -X POST -H 'Next-Action: 7f3a9c1e2b4d5f60a1b2c3d4e5f60718293a4b5c' -H 'Content-Type: text/plain;charset=UTF-8' --data-binary '[{"invoiceId":"42"}]'

Representative response — trimmed, and byte-level details shift between React 19 minors:

HTTP/1.1 200 OK
content-type: text/x-component
vary: RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Action

0:{"result":"ok","revalidated":true}
1:["$","main",null,{"children":"..."}]

Look at what came back: not just the return value, but a re-rendered RSC tree. Server Actions return both. People miss the second-order consequence — whatever your action returns is serialized and sent to the caller, so an action that returns a full user record for convenience is an unauthenticated data-shape decision waiting to become an incident.

Why the Endpoint Is Reachable Whether or Not Your UI Renders the Button

This is the part that breaks intuition. If you conditionally render a <form action={deleteInvoice}> only for admins, you have hidden a button. You have not hidden an endpoint.

For a client bundle to call the action, the ID has to be present in shipped JavaScript. So the practical gate is whether any client component delivered to that user imports the reference. But the endpoint is registered server-side at build time, and the ID is not a secret — it's a literal string in a JavaScript file served to browsers. Anyone who loads a page that imports the action can read the ID, and anyone who reads the ID can POST it with their own arguments.

Careful here, because "just don't render the button" is a genuinely common belief and the nuance matters: in the versions I checked, an ID that never appears in any client bundle is not practically callable, because it's a hash you'd have to guess. But "never appears in any client bundle" is a much stronger claim than "isn't rendered for this user." Verify against your build output instead of assuming.

The RSC Trust Boundary Nobody Drew

Middleware Matchers Versus the Real Route Table

Middleware matchers are written against paths. Server Action requests hit the page path — /dashboard, not /api/actions/deleteInvoice. Two failure modes follow.

First, matchers scoped to an API prefix miss action traffic entirely. A matcher like ['/api/:path*'] protects nothing Server Actions use.

Second, and subtler: a matcher that does protect /dashboard/:path* will run, but it protects the route, and the route is not the same object as the action. Middleware that checks "is there a session cookie" and lets the request through means every action reachable from that route now sits behind a cookie-presence check rather than an authorization check. A cookie is not an authorization decision.

An Authenticated Session Is Not an Authorized Action

This is ordinary IDOR wearing a new transport. The action receives arguments from the caller. If those arguments contain an object ID, the action must confirm that this session may act on that object. Authentication establishes who is calling; it says nothing about whether the caller may mutate invoice 42.

I've audited enough App Router code to say this pattern is closer to the norm than the exception:

"use server";

export async function updateInvoice(input: { id: string; total: number }) {
  // session check only — no ownership check on input.id
  const session = await auth();
  if (!session) throw new Error("Unauthorized");
  await db.invoice.update({ where: { id: input.id }, data: { total: input.total } });
}

That is a working cross-tenant write primitive behind a valid login. Nothing to do with the reported RCE, everything to do with why the "authless" framing distracts from what most apps actually get wrong.

From Flight Payload to Code Execution: Plausible Chains, Labeled as Inference

To be explicit: the following are hypotheses about a class of bug, not descriptions of the reported exploit. I don't know the reported chain.

ChainMechanismMy read
Parser-levelA crafted row or $ tag drives the deserializer into an unintended path — prototype pollution via a __proto__ key in a model row, or a reference resolved to something not meant to be reachable.Plausible, and would be genuinely pre-auth. Whether it's what the report describes: unknown.
Sink-after-parseDeserialization completes fine, then your action hands decoded input to eval, new Function, a shell, a template engine, or a dynamic import().Common in real code, but it isn't a framework vuln — it's an app vuln.
Dispatch abuseNo parser bug at all. The attacker calls a privileged action that was never meant to be callable by them, because authorization lived in the UI.The most likely explanation for most real-world "unauthenticated" action abuse I've seen.

If the report's chain turns out to be the third row dressed up as the first, the fix is in your app, not your lockfile. Worth remembering before you rewrite your auth layer in a panic.

How This Differs from Classic eval-Shaped Deserialization Bugs

It's tempting to analogize to Java native deserialization, Python pickle, or Node's node-serialize. The analogy is misleading in a useful way.

Those formats are open: they can express "instantiate this class and run its read hook," so the attacker's reach is limited mainly by the gadget classes on the classpath. Flight is closed by design. It has a whitelist of tags, it doesn't invoke constructors by name, and there's no user-controlled type instantiation in the format itself. Its risk profile is closer to a JSON parser with a dispatch table bolted on.

Which means the interesting surface is the dispatch table, not the parser. Nearly every realistic compromise of an App Router app goes like this: the attacker learns an action ID, sends well-formed arguments, and the action does something it shouldn't have done for that caller. Unglamorous, no CVE, and far more likely to be in your codebase right now than a deserialization bug in React.

Building an RSC Reproduction Lab You Control

Everything below is local-only. Don't point any of it at a deployment you don't own.

Pinned App Router Minimal App and What Versions to Record

npx create-next-app@latest rsc-lab --ts --app --eslint --no-tailwind --no-src-dir
cd rsc-lab
node -v
npm ls next react react-dom react-server-dom-webpack

Record all four versions in your notes. Flight internals are version-coupled; a finding on Next 15.x tells you nothing definitive about 14.x or 16.x. Also note whether react-server-dom-webpack resolves to exactly one copy — duplicates in the tree are a real source of confusing RSC behavior:

npm ls react-server-dom-webpack --all | grep -c 'react-server-dom-webpack@'

Capturing a Legitimate Action Request from the Browser

Add an action to the lab app, call it once from the UI, grab the request. In Chrome DevTools → Network, filter by Next-Action in the request headers panel, or right-click the request → Copy → Copy as cURL.

Write down four things:

  • the request URL (a page path, not /api/...)
  • the exact Next-Action value
  • the Content-Type and body encoding
  • the response content-type: text/x-component and row count

That capture is your baseline. Everything else in this section is a variation on it.

Replaying It Out of Band with curl, Including the Observed Output

Paste the copied cURL, then strip headers one at a time to find out which ones actually matter. In my experience the minimum viable replay is the action header, a content type matching the body, and the body itself:

curl -s -o /tmp/out.txt -w '%{http_code} %{content_type}\n' \
  'http://localhost:3000/dashboard' \
  -X POST \
  -H 'Next-Action: <captured-id>' \
  -H 'Content-Type: text/plain;charset=UTF-8' \
  --data-binary '[{"invoiceId":"42"}]'

Representative output from a correctly-written action that requires a session:

200 text/x-component

A 200 is not success. Read the body — a redirect-to-login or a thrown error still comes back as a 200 Flight stream. This is the most common triage mistake I see: people see 200 and assume the action ran.

grep -o 'NEXT_REDIRECT[^"]*' /tmp/out.txt | head -3
head -c 400 /tmp/out.txt

If the body contains your re-rendered shell rather than the mutation's result, the action didn't execute.

Now the test that actually answers the headline's question for your app:

VariantWhat you sendExpected on a correct app
No cookie, valid ID, valid argsPOST with no Cookie headerNon-execution: error row, redirect, or a 401/403 — not a mutation
Valid session, valid ID, other user's object IDPOST with your cookie, invoiceId belonging to user B403 or equivalent; nothing written
No cookie, valid ID, malformed argsPOST with truncated Flight bodyFramework error, no stack trace leaked to the client

If variant one mutates data, you don't need a React CVE to have a critical finding. If variant two mutates data, you have an IDOR. Run all three before spending any time on the framework question.

💪

Log the exact request line and Next-Action value for each variant. When something unexpected happens, you want the byte-level request in your notes, not a memory of clicking a button.

Auditing Your Own RSC Server Action Surface

Enumerating Action IDs and Every Reachable Flight Endpoint

Work from both directions: find the IDs in the client, find the registrations in the server build.

## client-side: server references shipped to browsers
grep -RhoE 'createServerReference\)\("[0-9a-f]{20,}"' .next/static/chunks \
  | sort -u | wc -l

## server-side: what the build actually registered
ls -la .next/server/ | grep -i reference-manifest

That grep is fragile — minifier output varies by version. The fallback is to search chunks for long hex string literals and de-duplicate by hand. The manifest file is the more reliable inventory; check where it lives in your Next version, since the path has moved between majors.

For each ID you find, note which route's bundle it appears in, what the function does, and what it checks. That table is your real attack surface, and I'd bet it contains entries you don't remember writing.

Grepping for Sinks That Matter After Deserialization

rg -n --type ts --type js \
  'child_process|execSync|\beval\(|new Function\(|require\(.*input|import\(.*input' \
  app/ lib/ src/

Then a second pass for the sinks that don't match those patterns but matter just as much: raw SQL string construction, path.join with user input, template rendering, anything that writes to the filesystem. Any "use server" function whose input reaches one of those without a schema at the top is a finding.

Verifying Middleware Coverage Against the Actual Route Table

Middleware matchers are regexes, and regexes are where coverage quietly fails. Build the route list from the filesystem, not from memory:

find app -name 'page.tsx' -o -name 'route.ts' | sort

Then, for each protected route, replay the action request, not just a GET. A GET that redirects to login proves nothing about a POST carrying Next-Action. I've verified "middleware works" on a route and then found the action on that same route executing without a session, because the middleware returned early for non-GET methods. Easy bug to write, easy to miss.

RSC Audit Checklist: Layer, What to Test, Common Bug

LayerWhat to testCommon bug
BuildInventory every "use server" export and its IDUnknown action count; stale actions from deleted features
TransportReplay each action with no cookie, then with another user's cookie200 responses that look like success but are redirects
AuthorizationPer-action: does it check object ownership, not just session?Session check standing in for authorization
InputDoes a schema run on the first line, before any use?Hand-rolled validation three calls deep
SinksDoes decoded input reach eval, shell, SQL, or filesystem?String concatenation into a query
MiddlewareMatcher vs. real route list, POST includedAPI-prefixed matcher missing page-path actions
OutputDoes the action return more than the caller needs?Full records serialized back to the client
DetectionAre Next-Action values logged and anomaly-alerted?No visibility into action traffic at all

Hardening RSC Endpoints to Actually Reduce Blast Radius

Patch First: Version Pinning, Lockfile Review, and Upgrade Order

If an advisory exists, patching is the only thing that removes the vulnerability. Everything below reduces blast radius; none of it substitutes.

Upgrade react, react-dom, next, and the react-server-dom-* adapter together. A mismatched adapter against a patched React can leave you running a code path that isn't the fixed one. Then check for duplicates:

npm ls react react-dom react-server-dom-webpack

If a transitive dependency pins an old react-server-dom-webpack, force a single version with overrides (npm) or resolutions (yarn/pnpm), and re-run the audit after. Lockfile review matters here specifically because RSC adapter packages have a habit of appearing twice in trees that mix a framework with a component library.

For version ranges, use the primary advisory's numbers, not mine — I don't have them.

Authorization Inside the Action, Not Around It

Middleware and layouts render decisions. Actions make them. Put the check in the function that mutates data, and make it about the object, not the session.

lib/guarded-action.ts
import { z } from "zod";


type Ctx = { userId: string };

export function guarded<TIn, TOut>(
schema: z.ZodType<TIn>,
run: (input: TIn, ctx: Ctx) => Promise<TOut>,
) {
return async (raw: unknown): Promise<TOut> => {
  const session = await auth();
  if (!session?.user?.id) {
    // never leak internals to the caller
    throw new Error("Not authorized");
  }

  const parsed = schema.safeParse(raw);
  if (!parsed.success) {
    throw new Error("Invalid input");
  }

  return run(parsed.data, { userId: session.user.id });
};
}

Then every action is declared through it, and the ownership check sits in run where it's visible:

"use server";

export const updateInvoice = guarded(
  z.object({ id: z.string().uuid(), total: z.number().int().nonnegative() }),
  async (input, ctx) => {
    const owned = await db.invoice.findFirst({
      where: { id: input.id, ownerId: ctx.userId },
      select: { id: true },
    });
    if (!owned) throw new Error("Not found"); // don't distinguish 403 from 404
    await db.invoice.update({ where: { id: owned.id }, data: { total: input.total } });
    return { ok: true };
  },
);

Note the select in the ownership query. An ownership check that loads the whole row and then throws is one refactor away from leaking it.

Validating Input at the Deserialization Edge, Not Downstream

You can't hook React's deserializer without forking the framework, so "the edge" in practice is the first statement of every action — which the wrapper above enforces by construction. The rule I'd hold you to: no action body touches its argument before a schema parse. Not a property access, not a spread, not a truthiness check on a nested field. That keeps the untrusted window to one auditable line.

Two framework-level knobs are worth setting while you're in next.config.js:

  • serverActions.bodySizeLimit — cap it at what your largest legitimate action needs. The default is generous, and generous defaults are how you get memory-exhaustion bugs for free.
  • serverActions.allowedOrigins — relevant behind a proxy where the Host header and the browser's Origin legitimately differ. Next.js compares Origin against Host (plus this list) for Server Action requests.
📝

That origin check is CSRF-shaped, not authentication. A non-browser client can set both headers to match. Treat it as protection against a malicious page riding a user's cookies — not as protection against a direct attacker.

Removing or Gating Server Actions You Do Not Need

The cheapest hardening is deletion. Every "use server" export is an RPC method you defend forever. Concretely:

  • Delete actions from abandoned features. A dead adminDeleteUser still registered in the manifest is not dead.
  • Don't import mutating actions into client components that render on public pages. If the reference never ships, the ID isn't in a bundle a stranger can read.
  • Split your action modules: a public-page tree should not be able to pull in the admin action file through a shared barrel export. Barrel files are how a newsletter form ends up importing the delete-everything action.

That last one I'd fix first in most codebases, because it's an import-graph problem with an import-graph fix, not a security-program problem.

Detection: Logging Replayed Action IDs and Anomalous Next-Action Traffic

You cannot detect what you don't log. At the edge or in reverse-proxy logs, capture:

  • the Next-Action header value (hash it if IDs feel sensitive — they aren't secrets, but log hygiene is cheap)
  • the request path, which should be a page route, not an API route
  • presence and consistency of Origin and Sec-Fetch-Site
  • whether the body is multipart or text

Signals worth alerting on, all of which need tuning against your baseline:

SignalWhy it's interesting
An action ID not seen in the last 30 daysReplay of a stale ID, or enumeration
High count of distinct action IDs from one sourceSomeone walking your client bundles
Server Action POSTs with no Origin headerMost browsers send Origin on POST; absence is non-conclusive but useful
Sec-Fetch-Site inconsistent with OriginForged or non-browser client
A single ID called at a rate no UI producesScripted replay
Action POSTs to routes with no corresponding client-side call, per your own telemetryRequests the real UI never generates

None of these are proof. Together they're the difference between finding out from a customer and finding out from your own dashboard.

The Takeaway: Treat Flight Endpoints as Public API

Whether or not this specific report survives scrutiny, the App Router's Server Action surface is the least-inventoried, least-authorized, least-monitored RPC surface in most JavaScript codebases — and it's public by default, reachable pre-auth, addressed by page path, and dispatched before your code runs.

Three things I'd act on this week, not after the advisory lands:

  1. Inventory before you patch. You can't judge your exposure to a framework CVE if you don't know how many action endpoints you ship or what they do. The manifest and a grep will tell you in an afternoon.
  2. Move authorization into the action. Middleware is a routing convenience. It has never been a security boundary, and the Server Action transport makes that failure mode worse, not better.
  3. Treat the deserializer as pre-auth code. If a flaw lives there, nothing you wrote downstream can save you — so patch discipline, single-version trees, and upgrade speed are the only real controls.

The "authless" headline is alarming because it's true of the architecture even when it isn't true of any particular bug. Every Server Action you export is an unauthenticated endpoint until you make it otherwise. That sentence is worth more than the RCE claim it arrived with.

Further Reading

Share this post

More posts

Loading comments…