Middleware was where the docs told you to put auth
Vercel introduced middleware in Next.js 11.1.4. The pitch was that you could intercept every request before the route handler ran, and redirect, rewrite, or block. The first example in the launch post was an authentication wall. The first example in every Next.js tutorial since has been an authentication wall. The official vercel/examples repository contains roughly a dozen middleware.ts files; nearly all of them do auth.
The shape that Vercel taught the ecosystem was:
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
export function middleware(req: NextRequest) {
const session = req.cookies.get('session')
if (!session) {
return NextResponse.redirect(new URL('/login', req.url))
}
}
export const config = { matcher: ['/admin/:path*', '/api/internal/:path*'] }
If middleware runs, the auth check runs. If middleware does not run, the auth check does not run, and the request proceeds straight to the matched route. That is the contract Vercel taught.
The recursion guard reads a header
A Next.js middleware can call fetch(). A fetch() to a path on the same site will hit middleware again. That second invocation can fetch() again. Without a brake, you can stack-overflow your own server by writing one wrong middleware.ts. Vercel handled this with a header.
Every outbound fetch() from inside middleware appends the current module's name to a colon-separated list in x-middleware-subrequest. On every inbound request, before invoking the middleware, the runtime reads the header, splits on :, counts how many times the current module's name appears, and returns "skip middleware" if the count crosses a threshold. The vulnerable code, from packages/next/src/server/web/sandbox/sandbox.ts in v14.2.24:
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',
},
}),
}
}
// ... edge function loaded and invoked here, only if depth < MAX_RECURSION_DEPTH
Read the lines in order. Line 3 reads params.request.headers['x-middleware-subrequest'] directly: no filtering, no origin check, no allowlist. Lines 7 through 10 count occurrences of params.name. Lines 12 through 21 return early with the response header x-middleware-next: 1, which is the runtime's instruction to "the middleware finished and decided not to interrupt; proceed to the matched route."
The runtime's instruction is "skip middleware." The decision input is one inbound header.
Six colons, no auth, your route runs
params.name for an app-router root middleware is the manifest name middleware. For different layouts the value can be pages/_middleware, src/middleware, src/middleware/index. The exact value lives in .next/server/middleware-manifest.json after a build, or in the Next.js source if you are working from outside the deployment. The exploit is one curl:
curl -i 'https://target.example.com/admin/users' \
-H 'x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware'
Server-side, subreq is the supplied string. subrequests is the five-element array. depth is 5. 5 >= MAX_RECURSION_DEPTH is true. The function returns a response carrying x-middleware-next: 1. The Next.js server reads that header, decides middleware is done, and routes the request to /admin/users. The middleware module that contained your if (!session) redirect('/login') is never loaded.
The route handler runs as if the auth gate had let it through. The auth gate did not let it through. The auth gate did not run.
The exact module name is the only piece that varies by deployment. Public exploit code from disclosure week iterated through middleware, src/middleware, pages/_middleware, src/pages/_middleware, with five repetitions of each, in a single header. There is no rate limit on auth-bypassed endpoints, because, from the application's perspective, no auth check failed.
The 2024 commit moved the threshold from one to five
Open the file's git history. The recursion threshold was added on February 6, 2024 by commit 6d07c00, titled "fix: allow some recursion for middleware subrequests." Before the commit, the check was a flat membership test. After it, a count-and-compare:
- if (subrequests.includes(params.name)) {
- return skipResponse
- }
+ const MAX_RECURSION_DEPTH = 5
+ const depth = subrequests.reduce(
+ (acc, curr) => (curr === params.name ? acc + 1 : acc),
+ 0
+ )
+ if (depth >= MAX_RECURSION_DEPTH) {
+ return skipResponse
+ }
Before February 2024, the bypass required exactly one occurrence of the middleware name in the header. After February 2024, it required five. The PR description does not mention security. The PR description discusses an internal Slack thread about Vercel-production behavior and a screenshot comparison of "no forwarding" vs. "with forwarding."
A Vercel engineer, in the same PR's notes, wrote: "Currently limited by fetches having to forward the subrequest header for each request which isn't ideal. Need some assistant on how to access the request in the module context fetch override."
The note reads as architectural awkwardness. It is a description of the bug. The header is forwarded on every fetch because the framework has no other channel for this signal. The framework has no other channel because the signal was designed as in-band metadata on the same HTTP-shaped pipe that carries inbound network traffic. The author asks for assistance accessing "the request in the module context fetch override." Nobody answered that the right answer is to not put this signal on a request header at all.
The thirteen months between this commit and the eventual security advisory are thirteen months in which the depth counter was the only thing standing between an external caller and the middleware-skip path. Six colons instead of one.
The patch did not delete the skip path
The advisory landed March 21, 2025. The fix, commit 52a078d, "Update middleware request header," does this:
+ const randomBytes = new Uint8Array(8)
+ crypto.getRandomValues(randomBytes)
+ const middlewareSubrequestId = Buffer.from(randomBytes).toString('hex')
+ ;(globalThis as any)[Symbol.for('@next/middleware-subrequest-id')] =
+ middlewareSubrequestId
+ if (
+ header === 'x-middleware-subrequest' &&
+ headers['x-middleware-subrequest-id'] !==
+ (globalThis as any)[Symbol.for('@next/middleware-subrequest-id')]
+ ) {
+ delete headers['x-middleware-subrequest']
+ }
+ init.headers.set(
+ 'x-middleware-subrequest-id',
+ (globalThis as any)[Symbol.for('@next/middleware-subrequest-id')]
+ )
The patch generates an 8-byte random identifier at server startup, stashes it in a global symbol, and: on outbound subrequests originating from inside middleware, sets x-middleware-subrequest-id to the secret; on inbound requests, strips x-middleware-subrequest if x-middleware-subrequest-id does not match the secret.
The vulnerable code in sandbox.ts is not touched. The depth-count comparison still runs. The early-return that skips middleware still runs. The fix is upstream of the comparison: another check, in another file (packages/next/src/server/lib/server-ipc/utils.ts), that strips the dangerous header before it reaches run().
The post-patch behavior, when the secret matches, is: trust the inbound header. The post-patch behavior, when the secret does not match, is: drop the header. The semantics of the header itself, the contract that "if this header reads N occurrences of your module name, skip yourself," is preserved exactly.
The comment the patch author left on the new filter is: "If this request didn't origin from this session we filter out the 'x-middleware-subrequest' header so we don't skip middleware incorrectly."
The framing is "skip middleware incorrectly." Not "an external caller can skip your auth check." The author corrects an off-by-one in trust evaluation; they do not retract the assumption that an inbound header should be empowered to skip middleware in the first place.
Internal-only by convention
Three years and seven months elapsed between Next.js 11.1.4 (August 2021) and the advisory (March 2025). For all of them, an HTTP header that nobody outside the framework was supposed to know about could turn off the framework's documented authentication surface.
The header is in the framework's own source. The framework is open source. The bypass is one search away from anyone who reads packages/next/src/server/web/sandbox/sandbox.ts. The protection of the header was that nobody had read that file with the question "what would happen if I sent this from outside" in mind.
That is what "internal-only" meant in this codebase. Not a check, not an allowlist, not a network boundary, not a token. A convention that Vercel kept and the network did not.
This is the trust inversion shape applied one floor down from where the catalog usually finds it. The trust artifact is not a maintainer account or a signing UI; it is a request header the framework defined as its own internal IPC. The recursion guard had authority to skip middleware. The recursion guard's input was a network-supplied string. The thing that authorized the skip was, in the framework's view, the framework. In the network's view, anyone with curl.
Vercel's documentation says to put authentication in middleware. Vercel's runtime says any inbound request with the right header skips middleware. Both statements are public record. Both cannot be accurate.
PoC: projectdiscovery/nuclei-templates
A convention that Vercel kept and the network did not.