cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

Case study: the middleware bypass

How CVE-2025-29927 let one header skip Next.js middleware, who was affected, how it was fixed, why checks belong next to the data, and how to read an advisory.

By the end, you can
  • 01
    Explain the bypassSay what the x-middleware-subrequest header did inside Next.js, why an outside request could use it, and how 15.2.3 and 15.2.4 fixed it.
  • 02
    Tell who was exposedRead the affected and patched versions, and the hosting that mattered, and decide whether an app was open.
  • 03
    Build checks that survive a skipKeep a second check next to the data, and read advisories and npm audit before an attacker does.

One header that skipped the check

In March 2025, many Next.js apps guarded their private pages the way the tea shop guards /stock: a middleware.ts that sends visitors without a session cookie to /sign-in. (Since Next.js 16 the same file is proxy.ts.) On March 21, 2025, a security advisory, CVE-2025-29927, said that anyone could skip that file entirely. No cookie, no password: one extra header in the request.

The request that skipped middleware.tsText
GET /stock HTTP/1.1Host: tea-shop.examplex-middleware-subrequest: middleware:middleware:middleware:middleware:middleware

Line 1 asks for the staff page, and line 2 names the shop. Line 3 is the whole attack: a header called x-middleware-subrequest, whose value is the word middleware five times. On an affected Next.js, served with next start, middleware didn't run for this request, so its redirect never happened, and the page answered as if the visitor were signed in.

No recording here installs an affected Next.js: the code from before the fix is quoted from its release instead. What can be recorded is today's behavior. Here is the shop's staff page in Next.js 16.4.3, with the same check in proxy.ts, asked for with the same header:

The 2025 header, sent to proxy.ts in Next.js 16.4.3Next.js
app/sign-in/stock/lib/
proxy.ts
import { NextResponse, type NextRequest } from "next/server"; export function proxy(request: NextRequest) {  if (!request.cookies.has("session")) {    return NextResponse.redirect(new URL("/sign-in", request.url));  }  return NextResponse.next();} export const config = { matcher: "/stock/:path*" };
Recorded from Next.js 16.4.3

GET /stock307

Sent with the request
x-middleware-subrequest middleware:middleware:middleware:middleware:middleware
Response headers
location /sign-in

No HTML arrived: the body is only the new address, as plain text

/sign-in

Line 4 of proxy.ts checks for a cookie named session, and line 5 redirects without one. Line 10's matcher covers /stock and everything under it. The Network tab opens on the request with the 2025 header: 307, location: /sign-in, the same answer as without it. The third request sends the trick with proxy's name instead, proxy:proxy:proxy:proxy:proxy: 307 again. proxy.ts ran every time.

Two requests for slash stock without a cookie, on Next.js 15.2.2. Top: no header. The request goes to middleware.ts, which answers 307 to slash sign-in. Bottom: with the x-middleware-subrequest header. middleware.ts is skipped, drawn crossed out, and an arrow goes around it straight to the stock page, which shows the stock to anyone.no headerGET /stockmiddleware.ts307 to /sign-inwith the header, on 15.2.2GET /stock + headermiddleware.tsthe stock pageevery tea, to anyoneskipped
On an affected Next.js, served with next start. Without the header, middleware.ts runs and redirects. With x-middleware-subrequest, Next.js never runs it, and the page answers as if the visitor were signed in.
Real-life analogyThe guard's "already checked" sticker

A guard checks badges at the staff door. Staff who carry boxes in and out all day get a sticker, so the guard doesn't check them again and again. One day someone notices that the sticker is plain paper: print one, walk past the guard. In shops where the stockroom had its own lock, the stock was still safe.

In real life: A guard at the staff door checks every badge
In Next.js: middleware.ts checks every request to /stock
In real life: Staff carrying boxes inside get a sticker: "already checked"
In Next.js: Middleware's own requests got x-middleware-subrequest
In real life: The guard waves through anyone with the sticker
In Next.js: Next.js skipped middleware when the header said so
In real life: Anyone could print the sticker at home
In Next.js: Any visitor could send the header
In real life: The stockroom has a lock of its own
In Next.js: getStock() checks the session next to the data
The main idea

CVE-2025-29927 let any request skip middleware by sending a header that Next.js used internally. Apps whose only access check was in middleware were open; apps that checked again next to their data were not. Next.js 15.2.3 (and 14.2.25, 13.5.9 and 12.3.5) fixed it, and 15.2.4 removed the header's handling altogether. The lesson for your own code: never let one check be the only one.

Why Next.js trusted the header

Middleware can call fetch, and it can fetch a page of its own app. That request comes back through the server, and middleware runs for it too. If that run fetches again, the app calls itself forever. So Next.js marked the requests middleware made: each one carried x-middleware-subrequest, a list of the middleware names that had already run, joined by colons. When the list got long enough, Next.js stopped running middleware for that request.

Here is the check, from lines 94 to 114 of sandbox.ts in Next.js 15.2.2, the last 15.2 release before the fix. Its line 1 is line 94 of the file:

The check that read the header, from sandbox.ts at v15.2.2TypeScript
export const run = withTaggedErrors(async function runWithTaggedErrors(params) {  const runtime = await getRuntimeContext(params)  const subreq = params.request.headers[`x-middleware-subrequest`]  const subrequests = typeof subreq === 'string' ? subreq.split(':') : []   const MAX_RECURSION_DEPTH = 5  const depth = subrequests.reduce(    (acc, curr) => (curr === params.name ? acc + 1 : acc),    0  )   if (depth >= MAX_RECURSION_DEPTH) {    return {      waitUntil: Promise.resolve(),      response: new runtime.context.Response(null, {        headers: {          'x-middleware-next': '1',        },      }),    }  }

In plain words, line by line:

  1. Line 3 reads the header from the incoming request. Nothing checks who added it: Next.js itself, or a visitor.
  2. Line 4 splits it at each colon: middleware:middleware becomes two names.
  3. Line 6 sets the limit to 5. Lines 7 to 10 count how many of the names are this middleware's name, params.name, which is middleware for a middleware.ts at the project's root.
  4. Line 12: when the count reaches 5, lines 13 to 20 return an answer without running middleware at all. Its header, x-middleware-next: 1, means "carry on to the route", the same as NextResponse.next().

So middleware:middleware:middleware:middleware:middleware counts 5, and middleware is skipped. In Next.js 12, the check was simpler: lines 73 to 84 of sandbox.ts at v12.3.4 skipped middleware if the name appeared even once.

The header value middleware:middleware:middleware:middleware:middleware, split at its colons into five cards that each say middleware, numbered 1 to 5. An arrow goes to a card: depth is 5. Another arrow goes to a card: 5 is at least MAX_RECURSION_DEPTH, so skip middleware.middleware:middleware:middleware:middleware:middlewaresplit(':')middleware1middleware2middleware3middleware4middleware5depth = 55 >= 5: skipmiddleware never runs
Lines 4 and 7 to 10 of the excerpt: the header is split at each colon, and the names equal to middleware are counted. Five of them reach MAX_RECURSION_DEPTH, so line 12 skips middleware.

The mistake wasn't the loop protection. It was believing a header that the outside world can send. Headers are text the browser, or any program, writes; a request can carry any header with any value.

Who was affected

The advisory, GHSA-f82v-jwr5-mffw, lists the affected versions in ranges, one per release line. Each row gives the first affected version and the first version with the fix; everything from the first up to, but not including, the second was open.

The affected versions
Release lineAffected fromFixed in
11.x and 12.x11.1.412.3.5 (no 11.x release has the fix)
13.x13.0.013.5.9
14.x14.0.014.2.25
15.x15.0.015.2.3

Next.js's own advisory starts the first range at 11.1.4, and gives 11.x no fix, only the workaround below. GitHub's advisory database, the one npm audit reads, starts that range at 12.0.0. When two copies of an advisory disagree, go by the stricter one.

The version wasn't enough. Where the app ran mattered too. Each row is one way to run a Next.js app:

Where the app ran
HostingExposed?
next start, or output: "standalone" on your own serverYes. The request, header and all, reached Next.js's own routing, which ran middleware.
VercelNo. Its routing runs in a separate system, which Vercel's postmortem says kept it safe.
Netlify and Cloudflare WorkersNo. Their adapters keep routing separate too, as Vercel's postmortem confirmed.
A static exportNo. A static export has no server, so it has no middleware to skip.

These come from Vercel's postmortem, published on March 25, 2025: a report, after the fact, of what happened and when. It also explains why the bug sat for a while. The first report, on February 27, was about 12.x, so it got a low priority; it reached the Next.js team on March 14, and 14.2.25 and 15.2.3 shipped on March 17 and 18.

And an app was only open if middleware was where it made a security decision. Skipping a middleware that only rewrote addresses or added a header did no harm of that kind. Sort these apps:

Was this app's data exposed?
  • 15.2.2 with next start in Docker; the sign-in check only in middleware.ts.
  • 14.2.24 with output: "standalone"; the sign-in check only in middleware.ts.
  • 11.1.4 with next start; the sign-in check only in middleware.ts.
  • 15.2.2 on Vercel; the sign-in check only in middleware.ts.
  • 15.2.3 with next start; the sign-in check only in middleware.ts.
  • 13.5.6 as a static export.
  • 12.3.4 with next start; middleware checks the cookie, and every data function checks again.
Try it yourself
0 of 7 correct

Sort each app: was its staff data exposed by CVE-2025-29927, or did it stay safe?

Choose a category for every card. You can change an answer at any time; Reset clears them all.

The fix and the upgrade

The fix shipped in 15.2.3 on March 18, 2025, in commit 52a078d. When the server starts, it now makes a random id, and the requests middleware makes carry it in a second header, x-middleware-subrequest-id. This function, from lines 53 to 72 of server-ipc/utils.ts at that commit, runs on every request that comes in:

The fix: drop the header unless it carries this server's idTypeScript
export const filterInternalHeaders = (  headers: Record<string, undefined | string | string[]>) => {  for (const header in headers) {    if (INTERNAL_HEADERS.includes(header)) {      delete headers[header]    }     // If this request didn't origin from this session we filter    // out the "x-middleware-subrequest" header so we don't skip    // middleware incorrectly    if (      header === 'x-middleware-subrequest' &&      headers['x-middleware-subrequest-id'] !==        (globalThis as any)[Symbol.for('@next/middleware-subrequest-id')]    ) {      delete headers['x-middleware-subrequest']    }  }}

Lines 5 to 7 were already there: they delete headers from a list that only Next.js may set. Lines 9 to 18 are new. Lines 12 to 16 ask whether the request has x-middleware-subrequest without this server's id, and if so, line 17 deletes the header. A visitor can't know the id, which is new every time the server starts, so their header is gone before the check in sandbox.ts reads it.

The server starts with a random id, 9f3c. Top: a fetch made by middleware itself carries the header and the id 9f3c. filterInternalHeaders finds that the id matches, and keeps the header. Bottom: a visitor's request carries the header and no id. filterInternalHeaders deletes the header, so middleware runs.the server starts with a random id: 9f3cmiddleware's own fetchid 9f3cid matchesheader kepta visitor's requestno idno matchheader deletedmiddleware runs
9f3c stands for the random id the server makes when it starts. Only requests that middleware itself made carry it. Any other request loses the header before Next.js decides whether to run middleware.

Six days later, 15.2.4 (with 14.2.26) went further, in commit 3da7082: it removed the header's handling altogether, because middleware on the Node.js runtime had never had it. Nothing in Next.js 16.4.3 reads the header. Pick each file name and read the Network tab:

Playground: does the header still skip the check?
The shop's staff page, guarded by this fileNext.js
app/sign-in/stock/lib/
proxy.ts
import { NextResponse, type NextRequest } from "next/server"; export function proxy(request: NextRequest) {  if (!request.cookies.has("session")) {    return NextResponse.redirect(new URL("/sign-in", request.url));  }  return NextResponse.next();} export const config = { matcher: "/stock/:path*" };
Recorded from Next.js 16.4.3

GET /stock307

Sent with the request
x-middleware-subrequest middleware:middleware:middleware:middleware:middleware
Response headers
location /sign-in

No HTML arrived: the body is only the new address, as plain text

/sign-in
Try it yourself
proxy.ts

Next.js 16.4.3, with the 2025 header and with the same trick using proxy's name: every request without a session gets 307 to /sign-in. proxy.ts ran each time.

Both versions were built and run with Next.js 16.4.3 and asked for /stock without a cookie, then with the 2025 header. Only the file's name and its function's name change. Reset goes back to the first version.

Both answer 307 with the header. The old name, middleware.ts, still runs on the Edge runtime in Next.js 16.4.3, where the 2025 check lived, and its build warns that the name is deprecated.

To fix an affected app, find the version it runs, then upgrade to at least the fixed release of its line: 12.3.5, 13.5.9, 14.2.25 or 15.2.3. No 11.x release has the fix, so an 11.x app must move to a patched, supported major version, and until it can, strip the header in the server in front of Next.js, as shown below. For an app on 15.x:

Find the version, then upgrade within the major versionShell
npm ls next
npm install next@15

Line 1 prints the next your lockfile installed. Line 2 moves to the newest 15.x, which holds this fix, at least 15.2.3, and every fix after it. On an older major, the newest release of that major has at least its line's fix, but 13.x and 14.x get no fixes any more, so plan the move to 15.x or 16.x. The previous lesson covers the lockfile and npm ci. If you can't upgrade today, the advisory's workaround is to stop the header before it reaches Next.js. With nginx, a common server in front of next start, that is one line:

nginx: never pass the header on to Next.jsText
location / {  proxy_set_header x-middleware-subrequest "";  proxy_pass http://127.0.0.1:3000;}

Line 2 sets the header to an empty value, and nginx doesn't send a header whose value is empty, so Next.js never sees it. Line 3 sends the rest of the request on to next start.

Defense in depth: a second check next to the data

Some apps on affected versions lost nothing, because middleware wasn't their only check. Keeping more than one layer, so that one failing doesn't open everything, is called defense in depth. Next.js's docs describe the two layers: a quick optimistic check in proxy.ts, which only looks at the cookie, and a real check next to the data, in the functions that read it.

The header can't skip proxy.ts in Next.js 16.4.3, but the shop's proxy.ts can still be passed: it only checks that a cookie named session exists. Each version below got four requests: none, the 2025 header, a made-up cookie, and Asha's real one. The Network tab opens on the made-up cookie:

Playground: what stops a request that gets past proxy.ts?
The shop's staff page, behind the same proxy.tsNext.js
app/sign-in/stock/lib/
app/stock/page.tsx
import { Suspense } from "react";import { allStock } from "../../lib/db"; // proxy.ts already checked the session, so read the stock directly.async function StockList() {  const stock = await allStock();  return (    <ul>      {stock.map((row) => (        <li key={row.tea}>          {row.tea}: {row.boxes} boxes        </li>      ))}    </ul>  );} export default function StockPage() {  return (    <main>      <h1>Stock</h1>      <Suspense fallback={<p>Loading…</p>}>        <StockList />      </Suspense>    </main>  );}
Recorded from Next.js 16.4.3

GET /stock200

Sent with the request
cookie session=Asha.0000000000000000000000000000000000000000000000000000000000000000
Response headers
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache HIT
x-nextjs-prerender 1
x-nextjs-stale-time 300
X-Powered-By Next.js
Cache-Control s-maxage=31536000
Content-Type text/html; charset=utf-8

The HTML that arrived, one element per line, with the code of its 15 scripts left out

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/3cfn99dbrcvvp.js">
    <script src="/_next/static/chunks/1j_b1xq_woyiy.js" async=""></script>
    <script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
    <script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
    <script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
    <title>Tea shop</title>
    <script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
  </head>
  <body>
    <div hidden="">
      <!--$-->
      <!--/$-->
    </div>
    <!--&-->
    <!--&-->
    <main>
      <h1>Stock</h1>
      <!--$-->
      <ul>
        <li>Green tea<!-- -->: <!-- -->12<!-- --> boxes</li>
        <li>Mint tea<!-- -->: <!-- -->4<!-- --> boxes</li>
      </ul>
      <!--/$-->
    </main>
    <!--$-->
    <!--/$-->
    <!--/&-->
    <!--/&-->
    <script id="_R_">…</script>
    <script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
  </body>
</html>
Try it yourself
The page trusts proxy.ts

proxy.ts only checks that a cookie named session is there. A made-up one passes, and this page reads the stock directly: 200 with every tea. The build even prerendered the page, ○, with a year-long s-maxage.

Both versions were built and run with Next.js 16.4.3 and got the same four requests. proxy.ts, lib/session.ts and lib/stock.ts are the same in both; only app/stock/page.tsx changes. Reset goes back to the first version.

The page that trusts proxy.ts reads allStock() directly, on line 6, and gave the made-up cookie every tea. Worse, the build prerendered it (○ /stock) with Cache-Control: s-maxage=31536000, so a shared cache in front of the app could keep the staff page for a year and hand it to anyone. The page that checks calls getStock() on line 5, which verifies the cookie's signature itself, with the same readSession as in proxy.ts, and answered Only staff can see the stock. Asha's real cookie still got the list.

lib/stock.ts: the check next to the dataTypeScript
import "server-only";import { cookies } from "next/headers";import { readSession } from "./session";import { allStock } from "./db"; export async function getStock() {  const session = readSession((await cookies()).get("session")?.value);  if (!session) return null;  return allStock();} 

Line 7 reads the session from the cookie with readSession, which checks the signature. Line 8 returns nothing without a valid one. Every page, Route Handler and Server Action that reads the stock goes through here, so a skipped proxy.ts, a matcher that misses a path, or a forged cookie all end at the same null.

Four requests, each going through two checks. Column one, proxy.ts: is there a session cookie? Column two, getStock: is the cookie signed? No cookie: proxy.ts stops it with a 307, and getStock is never reached. A made-up cookie: proxy.ts lets it in, getStock stops it. The 2025 header, on 15.2.2: proxy.ts is skipped, getStock stops it. Asha's cookie: proxy.ts lets it in, and getStock gives the stock.requestproxy.ts: a cookie?getStock(): signed?no cookiestops it: 307never reachedmade-up cookielets it instops itheader, on 15.2.2skippedstops itAsha's cookielets it inthe stock
The first check is quick and only looks for a cookie. The second, next to the data, checks the signature. A request that gets past the first, by a forged cookie or a flaw like the 2025 header, still meets the second.
It happened again in 2026

In May 2026, GHSA-267c-6grr-h53f described another way to reach a page without its middleware or proxy check: special addresses the router uses for prefetching, which the matcher didn't cover. The first fix, in 16.2.5 and 15.5.16, missed middleware.ts built with Turbopack; a follow-up advisory, GHSA-26hh-7cqf-hhc6, completed it in 16.2.6 and 15.5.18. So the versions with the complete fix are 16.2.6 or later, and 15.5.18 or later. Its workaround, until you upgrade: enforce authorization in the route or page itself. Authorization and the data access layer builds that layer for the whole shop.

Reading a security advisory

A security advisory is a public warning: this package, these versions, this flaw, this fix. GitHub keeps them in its advisory database, each with a GHSA id; many also get a CVE id, from a shared list of known flaws. Each row is one part of GHSA-f82v-jwr5-mffw:

The parts of the advisory
PartWhat it saysWhat it tells you
Packagenext, on npm.Whether you use it: npm ls next says.
Affected versions>= 15.0.0, < 15.2.3, and one range for each older line.Whether your version is open.
Patched versions15.2.3, 14.2.25, 13.5.9, 12.3.5.The smallest upgrade that closes it, in your line.
SeverityCritical, CVSS 9.1.How bad it is if an attacker uses it, from 0 to 10.
WeaknessesCWE-285, Improper Authorization.The kind of mistake, from a shared list of weakness types.
WorkaroundsStop outside requests with the header from reaching Next.js.What to do today, if you can't upgrade yet.

The severity comes from CVSS, a shared way to score flaws from 0 to 10. 9.1 is critical. The advisory also gives the score's reasons as a short code, its vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N. Each row is one part of it:

The CVSS vector, part by part
PartWhat it stands forFor this flaw
AV:NAttack vector: network.It works over the internet.
AC:LAttack complexity: low.No special conditions: one header.
PR:NPrivileges required: none.No account, no sign-in.
UI:NUser interaction: none.Nobody has to click anything.
S:UScope: unchanged.The score counts the impact on the Next.js app itself, not on other systems.
C:H/I:H/A:NConfidentiality high, integrity high, availability none.At worst, whatever the skipped check guarded could be read or changed, depending on what it guarded. A:N means the score gives no direct impact on whether the app stays up.

You don't have to read advisories one by one. npm audit compares your lockfile with the advisory database. This is what it printed on October 30, 2026 for a project whose lockfile pins next@15.2.2:

npm audit for a project on next@15.2.2 (shortened)Text
$ npm audit# npm audit report next  9.3.4-canary.0 - 16.3.0-preview.10Severity: critical…Authorization Bypass in Next.js Middleware - https://github.com/advisories/GHSA-f82v-jwr5-mffw…Next.js has a Middleware / Proxy bypass in App Router applications via segment-prefetch routes - https://github.com/advisories/GHSA-267c-6grr-h53f…fix available via `npm audit fix --force`Will install next@15.5.28, which is outside the stated dependency rangenode_modules/next

Line 4 names the package and the versions that have at least one of its advisories; line 5 gives the worst severity among them. Line 7 is the 2025 advisory, and line 9 the 2026 one; the full list for 15.2.2 is much longer, because many fixes came after it. Line 11 offers a fix, and line 12 warns that it's outside the stated dependency range: the project pins exactly 15.2.2, so the fix changes package.json. Read the release notes before you let --force do that.

And to hear about the next one before anyone uses it:

  • Dependabot alerts. GitHub can watch your repository's lockfile and tell you when an advisory matches a version in it, and open a pull request with the upgrade.
  • Watch the package. On a package's GitHub repository, Watch, then Custom, then Security alerts sends you its advisories as they're published.
  • Stay on a supported version. Next.js's support policy says which major versions still get security fixes: on October 30, 2026, 16.x and, until January 31, 2027, 15.x.

Checking the tea shop after an advisory

When an advisory names a package you use, work through the same steps every time. Type a version into the playground to try the first two:

Playground: is this version affected?

The advisory's ranges

  • 11.x and 12.x: from 11.1.4, fixed in 12.3.5
  • 13.x: from 13.0.0, fixed in 13.5.9
  • 14.x: from 14.0.0, fixed in 14.2.25
  • 15.x: from 15.0.0, fixed in 15.2.3
Try it yourself
15.2.2 is affected

It's in the 15.x range: from 15.0.0 up to, not including, 15.2.3. On its own server, with the only check in middleware, it was open. The minimum fix in its line is 15.2.3; the newest release of a supported major version also has the fixes that came later.

This checks the version against the ranges in the advisory on GitHub, copied on October 30, 2026. Reset goes back to 15.2.2.
  1. Find your version. npm ls next prints the one installed from your lockfile, not the range in package.json.
  2. Compare it with the ranges. In range means open. Outside every range, check that you're on a line the advisory covers at all.
  3. Check your setup. The hosting, and whether the affected feature makes a security decision in your app.
  4. Upgrade, or use the workaround today. At least your line's fixed release, better the newest release of a supported major, deployed with your lockfile. If your line has no fixed release, like 11.x, move to a supported major, and strip the header in front of Next.js until you have.
  5. Prove it. Send the attack yourself, as the recordings here sent the header, and check that the answer changed.
  6. Ask what else would have stopped it. A check next to the data turns the next flaw like this into a non-event.

Common mix-ups

  • "Next.js 16 still has the bug, because proxy.ts is the same file." No. The recorded 16.4.3 answered 307 to the header for proxy.ts and for middleware.ts.
  • "Every Next.js app was open." No. Only affected versions on their own server, whose only check was in middleware. Vercel, Netlify, Cloudflare Workers and static exports were not.
  • "The fix was to block the header." No. 15.2.3 dropped it unless it carried the server's secret id, and 15.2.4 stopped reading it altogether.
  • "A check in proxy.ts is enough once you've upgraded." No. A made-up cookie passed the shop's optimistic check, and a 2026 advisory found another way past proxy. Check next to the data.
  • "Critical means someone already attacked me." No. Severity says how bad it would be; whether you're exposed depends on your version, your hosting and where your checks are.
  • "npm audit fix --force is always safe." No. It can move outside your version range, which may be a bigger upgrade than you meant.

Practice exercises

These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.

Exercise 1 · Warm-upCount the names

A request reaches Next.js 15.2.2 with x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware. What does depth come to, and does middleware run?

Starter codeTypeScript
export const run = withTaggedErrors(async function runWithTaggedErrors(params) {
  const runtime = await getRuntimeContext(params)
  const subreq = params.request.headers[`x-middleware-subrequest`]
  const subrequests = typeof subreq === 'string' ? subreq.split(':') : []

  const MAX_RECURSION_DEPTH = 5
  const depth = subrequests.reduce(
    (acc, curr) => (curr === params.name ? acc + 1 : acc),
    0
  )

  if (depth >= MAX_RECURSION_DEPTH) {
    return {
      waitUntil: Promise.resolve(),
      response: new runtime.context.Response(null, {
        headers: {
          'x-middleware-next': '1',
        },
      }),
    }
  }

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upWas 14.2.24 affected?

    A shop runs Next.js 14.2.24 with next start. Was it in the advisory's affected versions?

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeThe header against Next.js 16.4.3

      This middleware.ts runs in Next.js 16.4.3. A request for /stock has no cookie and the 2025 header. What does the shop answer?

      Starter codeTypeScript
      import { NextResponse, type NextRequest } from "next/server";
      
      export function middleware(request: NextRequest) {
        if (!request.cookies.has("session")) {
          return NextResponse.redirect(new URL("/sign-in", request.url));
        }
        return NextResponse.next();
      }
      
      export const config = { matcher: "/stock/:path*" };
      

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeThe made-up cookie

        With this app/stock/page.tsx, someone sends /stock the cookie session=Asha. followed by 64 zeros. What does the page show?

        Starter codeTSX
        import { Suspense } from "react";
        import { allStock } from "../../lib/db";
        
        // proxy.ts already checked the session, so read the stock directly.
        async function StockList() {
          const stock = await allStock();
          return (
            <ul>
              {stock.map((row) => (
                <li key={row.tea}>
                  {row.tea}: {row.boxes} boxes
                </li>
              ))}
            </ul>
          );
        }
        
        export default function StockPage() {
          return (
            <main>
              <h1>Stock</h1>
              <Suspense fallback={<p>Loading…</p>}>
                <StockList />
              </Suspense>
            </main>
          );
        }
        

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeThe smallest upgrade

          An app runs Next.js 13.5.6 and can't move to a new major version this week. Which version closes CVE-2025-29927 with the smallest step?

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeRead one part of the vector

            The advisory's vector includes PR:N. What does an attacker need to use the flaw?

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask who could send the header, what Next.js did with it, and which check was left.

              The middleware bypass quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What was x-middleware-subrequest for, inside Next.js?

                Choose an answer to see the explanation.

              2. Question 2 of 7In 15.2.2, how many times must the middleware's name appear in the header to skip it?

                Choose an answer to see the explanation.

              3. Question 3 of 7Which of these apps was not exposed, with the sign-in check only in middleware?

                Choose an answer to see the explanation.

              4. Question 4 of 7What did the fix in 15.2.3 change?

                Choose an answer to see the explanation.

              5. Question 5 of 7The recorder sent the 2025 header to the shop's proxy.ts in Next.js 16.4.3, without a cookie. What came back?

                Read the code, then predictTypeScript
                import { NextResponse, type NextRequest } from "next/server";
                
                export function proxy(request: NextRequest) {
                  if (!request.cookies.has("session")) {
                    return NextResponse.redirect(new URL("/sign-in", request.url));
                  }
                  return NextResponse.next();
                }
                
                export const config = { matcher: "/stock/:path*" };
                

                Choose an answer to see the explanation.

              6. Question 6 of 7With this stock page, a request with a made-up session cookie got the stock. Why?

                Read the code, then predictTSX
                import { Suspense } from "react";
                import { allStock } from "../../lib/db";
                
                // proxy.ts already checked the session, so read the stock directly.
                async function StockList() {
                  const stock = await allStock();
                  return (
                    <ul>
                      {stock.map((row) => (
                        <li key={row.tea}>
                          {row.tea}: {row.boxes} boxes
                        </li>
                      ))}
                    </ul>
                  );
                }
                
                export default function StockPage() {
                  return (
                    <main>
                      <h1>Stock</h1>
                      <Suspense fallback={<p>Loading…</p>}>
                        <StockList />
                      </Suspense>
                    </main>
                  );
                }
                

                Choose an answer to see the explanation.

              7. Question 7 of 7npm audit says the fix "will install next@15.5.28, which is outside the stated dependency range". What does that mean?

                Choose an answer to see the explanation.

              Key takeaways

              • Next.js marked middleware's own requests with x-middleware-subrequest, and skipped middleware when the header said so. Nothing checked that Next.js had added it.
              • Affected: 11.1.4 up to 15.2.2, in ranges per line, on your own server. Fixed in 15.2.3, 14.2.25, 13.5.9 and 12.3.5, and in no 11.x release; Vercel, Netlify, Cloudflare Workers and static exports were never open.
              • 15.2.3 dropped the header unless it carried the server's random id; 15.2.4 stopped reading it. Next.js 16.4.3 answers it with the proxy's own redirect.
              • A header is text anyone can write. Never let one decide who gets in.
              • Keep proxy.ts as a quick first check, and check again next to the data, where every way in passes.
              • Read advisories by range, patched version, severity and workaround; let npm audit and Dependabot do the watching.

              Remember the one-liner.
              One header skipped the door; the shops that locked the stockroom too lost nothing.

              Read it in Next.js's own code

              At release v16.4.3, Next.js removes the headers only it may set from every incoming request in lines 40 to 64 of server-ipc/utils.ts, the same function the fix changed. x-middleware-subrequest isn't in its list any more, because nothing reads it. The random id the fix added was made in lines 169 to 173 of router-server.ts at the fix's commit.

              Coming next: MDX content, the first lesson on content, background work and AI. It writes pages in Markdown with React components inside.

              CompleteFrontend Clear concepts. Working examples.