Get started
CVE-2026-23869: a developer's guide to the Next.js RSC DoS

CVE-2026-23869: a developer's guide to the Next.js RSC DoS

CVE-2026-23869 (CVSS 7.5) lets a single HTTP request burn a minute of CPU on any Next.js App Router endpoint. Detect, patch, and harden your PDF pipeline.

Axel12 min read

On April 8, 2026, Facebook disclosed two denial-of-service vulnerabilities in React Server Components: CVE-2026-23869 (CPU exhaustion, CVSS 7.5) and CVE-2026-23864 (memory exhaustion, CVSS 7.5). Every Next.js App Router app on a major version between 13.x and 16.x is exposed by default. PDF rendering pipelines are particularly attractive targets because they accept user-shaped data and pin a worker for the duration of the render. This guide explains what both CVEs do, who is affected, how to verify the patch path, and how to harden a Next.js PDF endpoint so the next deserialization bug does not take you down.

What CVE-2026-23869 actually does

CVE-2026-23869 is a CPU-exhaustion DoS in the React Server Components deserializer. A crafted HTTP body posted to any App Router Server Function endpoint triggers up to one minute of CPU saturation per request, ending in a catchable error. The CVSS 3.1 base score is 7.5 with vector AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H, network-accessible and unauthenticated. The CWE classifications are CWE-400 (Uncontrolled Resource Consumption) and CWE-502 (Deserialization of Untrusted Data) per the NVD entry.

The bug lives in the react-server-dom-* packages that Next.js, Vite, Parcel, React Router 7, RedwoodSDK, and Waku all consume. The deserialization step that turns a Server Function HTTP request body into a JavaScript value can be coerced into a pathological cycle, which the parser walks until a guard finally throws. React 19.2.5 fixes it with a single PR titled "Add more cycle protections" (PR 36236, by eps1lon and unstubbable), confirming that the root cause was missing cycle detection during RSC payload decoding. There is no remote code execution; impact is availability only.

Are you affected? A 60-second checklist

You are exposed today if all of the following are true:

  • Your app uses the Next.js App Router (the app/ directory) on version 13.3.0 or later, including 14.x, 15.x up to 15.5.14, or 16.x up to 16.2.2.
  • You expose at least one Server Action or Route Handler that React Server Components dispatches through, including any form action={...} that targets a Server Function.
  • The endpoint is reachable from the public internet, even behind an authenticated UI: the deserializer runs before your auth check.
  • You have not deployed the React 19.2.5, 19.1.6, or 19.0.5 line, and you have not bumped to Next.js 15.5.15 or 16.2.3.

Pure client-side React apps are not affected. Apps that use the Pages Router only and never expose a Server Function endpoint are not affected. Anything else needs the patch.

If you generate PDFs server-side from a Next.js Server Action or App Router POST handler, treat the endpoint as a confirmed exposure. Section 5 below explains why PDF endpoints make this worse.

How to verify your version and patch path

Run the following against your project root to dump the resolved versions of every React Server DOM package and Next.js itself:

npm ls next react react-server-dom-webpack react-server-dom-turbopack react-server-dom-parcel

Compare against the affected ranges from the Vercel advisory and the NVD entry:

PackageAffectedPatched
next13.3.0 through 14.x (EOL, no fix), 15.0.0 through 15.5.14, 16.0.0 through 16.2.215.5.15, 16.2.3
react-server-dom-webpack19.0.0 through 19.0.4, 19.1.0 through 19.1.5, 19.2.0 through 19.2.419.0.5, 19.1.6, 19.2.5
react-server-dom-turbopacksame as abovesame as above
react-server-dom-parcelsame as abovesame as above

Next.js 13 and 14 do not get a patched release because both branches are end-of-life. The supported migration path is to 15.5.15 or 16.2.3. Netlify's April 8, 2026 advisory explicitly recommends migrating off 13.x and 14.x and manually deleting any vulnerable deploy previews and branch deployments still served from those builds.

The patch

Update Next.js and React together. The two packages are versioned independently but the Server Components deserializer code paths cross both, so a partial upgrade will leave you exposed. The exact commands depend on your package manager.

# Next.js 16 line
npm install [email protected] [email protected] [email protected]
 
# Next.js 15 line
npm install [email protected] [email protected] [email protected]
 
# Verify
npm ls next react react-server-dom-webpack

After upgrading, redeploy and confirm that npm ls react-server-dom-webpack no longer shows any 19.0.0 through 19.0.4, 19.1.0 through 19.1.5, or 19.2.0 through 19.2.4 entries. Lockfile-only updates without a redeploy do not protect production traffic.

Treat this as a same-day upgrade in production. Vercel's WAF and Fastly's Next-Gen WAF virtual patch reduce the attack surface, but every advisory issued so far (Vercel, Fastly, Netlify, Akamai) says the same thing: WAF protection is not a substitute for the patched packages. A determined attacker with knowledge of the deserialization bug can reshape a payload around any pattern-based filter.

Why PDF generation pipelines are an outsized attack surface

PDF render endpoints exhibit every property that makes the RSC DoS exploitable in the worst way:

  1. They accept user-shaped data. A render request typically carries an arbitrary JSON object that drives the template, often deeply nested and often loosely typed. The shape of that input is exactly what a deserialization bug consumes.
  2. They live behind Server Actions or App Router POST handlers. Most Next.js PDF tutorials in 2024 and 2025 wired the render path to a Server Action because it was the simplest way to receive a FormData payload. Every one of those endpoints sits in the affected code path.
  3. They take real wall-clock time. A normal render is hundreds of milliseconds. A vulnerable render under attack is 60 seconds plus the normal render. Stack the two and a serverless function with a 90-second timeout gets pinned for its full budget per request.
  4. They consume worker concurrency. Playwright, Puppeteer, and Chromium each hold a browser context per request. Burning a worker for a minute means real PDF jobs queue or fail.
  5. They pass data to a template engine. Handlebars, EJS, and Liquid are the usual suspects. None of them has a hard cap on object depth or expansion factor by default. A {{#each}} over a 100k-element array compiles fine and renders forever.

Any Next.js PDF endpoint that takes more than ~5 KB of body, dispatches to a Server Action, and is reachable from the public internet should be considered exposed. The patch is necessary; the hardening below is what prevents the next bug in this category from being a production incident.

Hardening pattern: rate limit, size cap, schema, queue

Apply four layers around any Server Action or App Router route that triggers a PDF render. None is sufficient on its own.

1. Cap request size at the edge. Next.js does not enforce a body size limit on Route Handlers by default. Add an explicit check at the top of the handler.

2. Validate the payload shape with a strict schema. Reject unknown keys, cap array lengths, cap string lengths, cap object depth.

3. Rate-limit per IP and per API key. A token-bucket of 10 requests per minute per key is enough to make CPU exhaustion uneconomical without harming legitimate traffic.

4. Run the actual render in a worker or queue. The Server Action should enqueue, not render. The worker enforces its own per-job CPU and memory cap, so a bad job cannot starve the request handler.

Here is a hardened App Router POST handler that combines all four layers:

// app/api/render/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
 
const MAX_BODY_BYTES = 64 * 1024; // 64 KB hard cap
const MAX_DATA_DEPTH = 6;
const MAX_ARRAY_LEN = 500;
 
const renderSchema = z.object({
  template_id: z.string().min(1).max(64).regex(/^tmpl_[a-z0-9]+$/),
  data: z.record(z.unknown()).refine(
    (v) => objectDepth(v) <= MAX_DATA_DEPTH && arraysWithinCap(v, MAX_ARRAY_LEN),
    "data exceeds shape limits",
  ),
});
 
const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.tokenBucket(10, "60 s", 10),
});
 
export async function POST(req: NextRequest) {
  const apiKey = req.headers.get("authorization")?.replace(/^Bearer /, "");
  if (!apiKey) return jsonError(401, "missing api key");
 
  const { success } = await ratelimit.limit(apiKey);
  if (!success) return jsonError(429, "rate limited");
 
  const lengthHeader = Number(req.headers.get("content-length") ?? 0);
  if (lengthHeader > MAX_BODY_BYTES) return jsonError(413, "body too large");
 
  const raw = await req.text();
  if (raw.length > MAX_BODY_BYTES) return jsonError(413, "body too large");
 
  let parsed: unknown;
  try {
    parsed = JSON.parse(raw);
  } catch {
    return jsonError(400, "invalid json");
  }
 
  const result = renderSchema.safeParse(parsed);
  if (!result.success) return jsonError(400, result.error.issues[0]?.message);
 
  // Enqueue, do not render inline. The worker enforces its own CPU budget.
  const jobId = await enqueueRender(apiKey, result.data);
  return NextResponse.json({ job_id: jobId }, { status: 202 });
}
 
function jsonError(status: number, message: string) {
  return NextResponse.json({ error: { message } }, { status });
}

The point is not the specific libraries (Zod and Upstash are illustrative). The point is that every render endpoint should reject oversized bodies before parsing, validate the parsed payload against a tight schema, throttle per credential, and hand off to a worker with a CPU budget that is shorter than the request timeout.

The CVE-2026-23864 companion

CVE-2026-23864 was filed in the same Facebook advisory cycle and disclosed alongside CVE-2026-23869. Per the Vercel CVE-2026-23864 changelog, the bug is also a denial-of-service in the React Server DOM packages, but the failure mode is memory exhaustion rather than CPU saturation. An attacker sends a crafted Flight request, and the deserializer allocates an unbounded array that drives the worker into out-of-memory or crash conditions. Endor Labs' analysis of the broader React advisory describes the patch as "limits the array size that can be sent in a Flight request to prevent DoS attacks", confirming that the fix is a hard cap on serialized array length.

A side-by-side comparison:

PropertyCVE-2026-23869CVE-2026-23864
MechanismCycle in RSC payload triggers ~60s CPU loopUnbounded array allocation drives memory exhaustion
ImpactCPU saturation, catchable errorOut-of-memory, server crashes, function failures
CVSS7.5 (AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H)7.5
CWECWE-400, CWE-502CWE-400
React fix19.0.5, 19.1.6, 19.2.519.0.4, 19.1.5, 19.2.4
Next.js fix15.5.15, 16.2.3See Vercel changelog for the per-line patch matrix
RCENoneNone
DisclosureApril 8, 2026January 26, 2026, expanded April 8, 2026

The two CVEs are independent, but they share an attack vector (a crafted POST to a Server Function endpoint) and a remediation path (the same Next.js and React upgrades). If you have already deployed a 19.2.x line at 19.2.4 to fix CVE-2026-23864, you still need to bump to 19.2.5 to close CVE-2026-23869.

Detection: what to look for in logs

The default symptom is a sudden cluster of Server Function requests that each consume close to a minute of CPU and end with a thrown error. Three signals to grep for:

  1. Anomalous duration on POST /... to App Router routes. Plot p99 request duration per route. CVE-2026-23869 turns a baseline of 200ms into a 60-second spike.
  2. Repeated Maximum call stack size exceeded or generic deserialization errors in the Next.js server log immediately after a cluster of slow requests. The catchable error mentioned in the NVD description surfaces as a stack overflow in many runtimes.
  3. Concurrency saturation on serverless platforms. A handful of vulnerable requests can pin every available worker. On Vercel and Netlify, this shows up as queueing, cold start spikes, and increased function cost. Netlify's advisory specifically calls out the cost-inflation risk.

If any of these patterns appears before you have deployed the patch, deploy the patch first and inspect afterwards. The exploit is cheap and the bar to trigger it accidentally with a fuzzer is low.

What we did at PDF4.dev

Three pieces of pre-existing posture meant the disclosure window was a configuration check, not an incident:

  • Authenticated render only. Every public PDF render goes through POST /api/v1/render with a p4_live_* Bearer token resolved against a SHA-256 hash. Anonymous traffic cannot reach the Server Function deserializer for a render. The token-resolution step happens before any user-supplied JSON is parsed beyond JSON.parse in the Route Handler, which is not the affected RSC code path.
  • Bounded body and schema. Render requests carry a typed data object validated against the template's expected variables. We do not pass arbitrary nested structures into a Server Action. Logs and template payloads are written through structured queries, and lib/errors/sanitize.ts already strips infrastructure detail from any error that would otherwise leak.
  • Per-user SSE connection cap and session recheck. The dashboard live-events stream caps each user at 10 concurrent connections and re-validates the session every 30 seconds. The same cap applies to the worker pool that fans out renders. A pinned worker cannot cascade across organizations.

When the advisory landed, our action was the upgrade itself: bumping Next.js from 16.1.6 to 16.2.3 and confirming that react-server-dom-webpack resolved to 19.2.5 in the lockfile. Both versions are in the production deploy. We are also moving the public render endpoint to an explicit body-size guard at the edge (16 KB hard cap, returned as a structured 413), which closes a class of payload-shape attacks that has nothing to do with this specific CVE.

The lesson, for any PDF API on Next.js: posture compounds. Auth-gating, payload caps, schema validation, and worker isolation all read like belt-and-suspenders engineering until a deserialization bug turns every unauthenticated POST into a free minute of CPU. CVE-2026-23869 will not be the last bug in this class. Build the harness once, and the next CVE is a version bump instead of a postmortem.

References

Start generating PDFs

Build PDF templates with a visual editor. Render them via API from any language in ~300ms.