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.
- 01Explain 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.
- 02Tell who was exposedRead the affected and patched versions, and the hosting that mattered, and decide whether an app was open.
- 03Build 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.
GET /stock HTTP/1.1Host: tea-shop.examplex-middleware-subrequest: middleware:middleware:middleware:middleware:middlewareLine 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:
proxy.tsimport { 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*" };GET /stock307
- Sent with the request
x-middleware-subrequestmiddleware:middleware:middleware:middleware:middleware
- Response headers
location/sign-in
No HTML arrived: the body is only the new address, as plain text
/sign-inLine 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.
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.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
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:
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:
- Line 3 reads the header from the incoming request. Nothing checks who added it: Next.js itself, or a visitor.
- Line 4 splits it at each colon:
middleware:middlewarebecomes two names. - 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 ismiddlewarefor amiddleware.tsat the project's root. - 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 asNextResponse.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.
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.
| Release line | Affected from | Fixed in |
|---|---|---|
| 11.x and 12.x | 11.1.4 | 12.3.5 (no 11.x release has the fix) |
| 13.x | 13.0.0 | 13.5.9 |
| 14.x | 14.0.0 | 14.2.25 |
| 15.x | 15.0.0 | 15.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:
| Hosting | Exposed? |
|---|---|
next start, or output: "standalone" on your own server | Yes. The request, header and all, reached Next.js's own routing, which ran middleware. |
| Vercel | No. Its routing runs in a separate system, which Vercel's postmortem says kept it safe. |
| Netlify and Cloudflare Workers | No. Their adapters keep routing separate too, as Vercel's postmortem confirmed. |
| A static export | No. 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:
- 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.
Sort each app: was its staff data exposed by CVE-2025-29927, or did it stay safe?
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:
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.
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:
proxy.tsimport { 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*" };GET /stock307
- Sent with the request
x-middleware-subrequestmiddleware:middleware:middleware:middleware:middleware
- Response headers
location/sign-in
No HTML arrived: the body is only the new address, as plain text
/sign-inNext.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 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:
npm ls next
npm install next@15Line 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:
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:
app/stock/page.tsximport { 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> );}GET /stock200
- Sent with the request
cookiesession=Asha.0000000000000000000000000000000000000000000000000000000000000000
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheHITx-nextjs-prerender1x-nextjs-stale-time300X-Powered-ByNext.jsCache-Controls-maxage=31536000Content-Typetext/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>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.
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.
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.
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:
| Part | What it says | What it tells you |
|---|---|---|
| Package | next, 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 versions | 15.2.3, 14.2.25, 13.5.9, 12.3.5. | The smallest upgrade that closes it, in your line. |
| Severity | Critical, CVSS 9.1. | How bad it is if an attacker uses it, from 0 to 10. |
| Weaknesses | CWE-285, Improper Authorization. | The kind of mistake, from a shared list of weakness types. |
| Workarounds | Stop 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:
| Part | What it stands for | For this flaw |
|---|---|---|
AV:N | Attack vector: network. | It works over the internet. |
AC:L | Attack complexity: low. | No special conditions: one header. |
PR:N | Privileges required: none. | No account, no sign-in. |
UI:N | User interaction: none. | Nobody has to click anything. |
S:U | Scope: unchanged. | The score counts the impact on the Next.js app itself, not on other systems. |
C:H/I:H/A:N | Confidentiality 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# 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/nextLine 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:
The advisory's ranges
- 11.x and 12.x: from
11.1.4, fixed in12.3.5 - 13.x: from
13.0.0, fixed in13.5.9 - 14.x: from
14.0.0, fixed in14.2.25 - 15.x: from
15.0.0, fixed in15.2.3
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.
- Find your version.
npm ls nextprints the one installed from your lockfile, not the range inpackage.json. - Compare it with the ranges. In range means open. Outside every range, check that you're on a line the advisory covers at all.
- Check your setup. The hosting, and whether the affected feature makes a security decision in your app.
- 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.
- Prove it. Send the attack yourself, as the recordings here sent the header, and check that the answer changed.
- 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
307to the header forproxy.tsand formiddleware.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.
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?
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',
},
}),
}
}5: all five parts are middleware, the name of a middleware.ts at the root. 5 >= 5, so lines 13 to 20 answer x-middleware-next: 1 and middleware never runs.
A shop runs Next.js 14.2.24 with next start. Was it in the advisory's affected versions?
Yes. 14.2.24 is inside >= 14.0.0, < 14.2.25. On its own server, with the check only in middleware, it was open; 14.2.25 is the smallest upgrade that closes it.
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?
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*" };
307, with location: /sign-in. The recorded middleware.ts on Next.js 16.4.3 redirected the request with the 2025 header exactly as it redirected the one without it.
With this app/stock/page.tsx, someone sends /stock the cookie session=Asha. followed by 64 zeros. What does the page show?
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>
);
}
import { Suspense } from "react";
import { getStock } from "../../lib/stock";
async function StockList() {
const stock = await getStock();
if (!stock) return <p>Only staff can see the stock.</p>;
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>Checking your session…</p>}>
<StockList />
</Suspense>
</main>
);
}
The stock: Green tea: 12 boxes and Mint tea: 4 boxes, with a 200. The made-up cookie passed proxy.ts, and this page reads the stock without a check. The version with getStock() answered Only staff can see the stock.
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?
13.5.9, the 13.x fix, published on March 22, 2025, outside the support policy, after users asked. Today, also plan the move to a supported major version: 13.x gets no other fixes.
The advisory's vector includes PR:N. What does an attacker need to use the flaw?
No privileges: the attacker needs no account and no sign-in. With AV:N (over the network) and AC:L (low complexity), that's a big part of why the score is 9.1.
Check your understanding
For each question, ask who could send the header, what Next.js did with it, and which check was left.
Question 1 of 7What was
x-middleware-subrequestfor, inside Next.js?Choose an answer to see the explanation.
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.
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.
Question 4 of 7What did the fix in 15.2.3 change?
Choose an answer to see the explanation.
Question 5 of 7The recorder sent the 2025 header to the shop's
proxy.tsin Next.js 16.4.3, without a cookie. What came back?Read the code, then predictTypeScriptimport { 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.
Question 6 of 7With this stock page, a request with a made-up
sessioncookie got the stock. Why?Read the code, then predictTSXimport { 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.
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.
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.