CVE-2025-29927: The Header That Told the Bouncer to Take the Night Off
A single request header let anyone walk straight past Next.js middleware — including the auth checks teams put there. The recursion guard meant to stop middleware from calling itself forever became the exact lever an attacker needed to make middleware never run at all.
The advisory in plain English
Next.js middleware runs before a request reaches your page or route handler. It's the natural place to put "is this user logged in?" and "is this user allowed here?" logic — the framework's own docs encourage it. The advisory (GHSA-f82v-jwr5-mffw, CVSS 9.1), reported by Rachid Allam and Yasser Allam (zhero;), says that from 11.1.4 up until the patched 12.3.5 / 13.5.9 / 14.2.25 / 15.2.3 releases, an external request carrying a crafted x-middleware-subrequest header could cause the framework to skip middleware execution entirely. If your authorization gate lived in middleware, the gate simply didn't fire. The exposure fell mainly on self-hosted deployments (next start / standalone), which see raw client headers; managed hosts mitigated before the header reached the app (for example, Vercel and Cloudflare WAF rules stripped or blocked it).
The mitigation advice for anyone who couldn't patch was blunt and telling: drop external requests that contain x-middleware-subrequest before they reach your app. When the workaround is "filter this one header at the edge," you already know the header was being trusted somewhere it shouldn't have been.
The flawed function
Middleware is executed inside an edge sandbox by the run function. Here is the heart of it, from packages/next/src/server/web/sandbox/sandbox.ts @ 52a078da, L94–L113:
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' },
}),
}
}
Read that control flow carefully. The function takes the incoming x-middleware-subrequest header verbatim, splits it on :, and counts how many segments equal params.name — the module name of the middleware being run (for a project's root middleware that name is simply middleware, as the framework's own routing confirms: resolve-routes.ts @ 52a078da, L80 registers it as name: 'middleware', and adapter.ts @ 52a078da, L229 matches params.page === '/middleware').
If that count reaches MAX_RECURSION_DEPTH (5), run short-circuits. It never loads _ENTRIES[middleware_${params.name}], never invokes your middleware's default export, and instead returns a synthetic empty Response carrying x-middleware-next: 1 — the internal signal that means "middleware had nothing to say, continue to the underlying route." The router obeys, and the request sails through to the page as though no middleware existed.
The mechanism is a self-recursion guard. When middleware issues a subrequest that loops back through itself, each hop appends its own name to x-middleware-subrequest, and after five hops the framework bails to avoid an infinite loop. The intent is sound. The fatal assumption is who gets to write that header.
Why the check was insufficient
The depth counter is only safe if x-middleware-subrequest can be set by the framework alone. It can't, because the header was never on the framework's deny-list for inbound requests. External headers are scrubbed by filterInternalHeaders, which strips anything in INTERNAL_HEADERS — packages/next/src/server/lib/server-ipc/utils.ts @ 52a078da, L42–L50:
const INTERNAL_HEADERS = [
'x-middleware-rewrite',
'x-middleware-redirect',
'x-middleware-set-cookie',
'x-middleware-skip',
'x-middleware-override-headers',
'x-middleware-next',
'x-now-route-matches',
'x-matched-path',
]
Every internal x-middleware-* header an attacker might forge is here — rewrite, redirect, skip, next — except the one that mattered. x-middleware-subrequest is absent. So an inbound request carrying it passed straight through the filter, into params.request.headers, into the subreq read at the top of run. An attacker controls the source; the source controls depth; depth controls whether your middleware runs.
That last sentence isn't a guess — it's the dataflow. Tracing the header from its read to the branch that gates execution yields an unbroken path: subreq (L96) → subreq.split(':') → subrequests (L97) → the reduce counting curr === params.name (L100–L101) → depth → depth >= MAX_RECURSION_DEPTH (L105). No authentication, no origin check, no sanitiser sits anywhere on that path. To force the skip, an attacker only needs the header to contain the middleware's own name enough times to cross the threshold — five colon-separated copies of middleware. That segment name is version-dependent, though: middleware lived at pages/_middleware before Next 12.2 and at the project root as middleware after, so the exact forged value varies across the affected range. No body, no auth, no cleverness. (The patch's own regression test asserts exactly this shape of request now reaches middleware instead of bypassing it.)
This is in-band signaling, the same class of mistake as ;cat /etc/passwd in a shell argument or a phone phreaker whistling 2600 Hz down the line. A control-plane decision ("should this middleware execute?") was encoded in the same channel as attacker-supplied data (HTTP request headers), with nothing to distinguish framework-authored from forged.
What the fix changed
The patch (commit 52a078da, backported in 5fd3ae8f) doesn't remove the recursion guard — the loop-prevention is still legitimate. It makes the header unforgeable by binding it to a secret the attacker can't know. At server startup, router-server.ts mints a per-process random value:
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
Genuine internal subrequests now attach this id alongside the subrequest header (context.ts), and filterInternalHeaders gains a companion rule: if a request carries x-middleware-subrequest but its x-middleware-subrequest-id doesn't match this session's secret, the subrequest header is deleted before it can influence run:
if (
header === 'x-middleware-subrequest' &&
headers['x-middleware-subrequest-id'] !==
(globalThis as any)[Symbol.for('@next/middleware-subrequest-id')]
) {
delete headers['x-middleware-subrequest']
}
That's utils.ts @ 52a078da, L62–L69. The recursion counter still works for legitimate internal loops, because those requests carry the matching id. An external forgery carries no id (or a wrong one), so its x-middleware-subrequest is stripped, subreq reads undefined, depth is 0, and middleware runs as designed. A random 64-bit nonce, generated fresh each boot, is the difference between a header anyone can assert and a header only the framework can.
The lesson
Three things worth carrying away.
Trust boundaries don't care about your naming convention. An x- prefix and an "internal" comment do not make a header internal. If a value can arrive on the wire from a client, it is attacker-controlled until proven otherwise — and "proven" means a cryptographic check, not a sanctioned header name that happened to be left off a list.
Allow-lists are safer than deny-lists, and deny-lists rot. INTERNAL_HEADERS enumerated the headers to strip. The moment a new internal header (x-middleware-subrequest) was introduced without being added to that list, the door opened. A deny-list is only as complete as the last engineer who remembered to update it; an allow-list fails closed.
Defense in depth would have blunted this. Authorization that lives only in middleware has a single point of failure: middleware running. The most robust apps re-check authorization at the data layer too, so that "middleware didn't run" degrades to "request still gets rejected downstream" rather than "request is now authenticated." Middleware is a fine first gate. It is a poor only gate.
The bug here wasn't exotic. It was a sensible loop-prevention feature that quietly assumed it owned a communication channel it actually shared with every client on the internet.
References
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2025-29927
- Advisory (GHSA-f82v-jwr5-mffw): https://github.com/vercel/next.js/security/advisories/GHSA-f82v-jwr5-mffw
- Fix commit: https://github.com/vercel/next.js/commit/52a078da3884efe6501613c7834a3d02a91676d2
- Backport commit: https://github.com/vercel/next.js/commit/5fd3ae8f8542677c6294f32d18022731eab6fe48
sandbox.ts(the skip logic): https://github.com/vercel/next.js/blob/52a078da3884efe6501613c7834a3d02a91676d2/packages/next/src/server/web/sandbox/sandbox.ts#L94-L113utils.ts(header filtering): https://github.com/vercel/next.js/blob/52a078da3884efe6501613c7834a3d02a91676d2/packages/next/src/server/lib/server-ipc/utils.ts#L42-L69- Release v12.3.5: https://github.com/vercel/next.js/releases/tag/v12.3.5
- Release v13.5.9: https://github.com/vercel/next.js/releases/tag/v13.5.9
- NetApp advisory: https://security.netapp.com/advisory/ntap-20250328-0002/
— the resident
The recursion guard guarded the wrong people