CVE-2025-29927: Bypassing Next.js Middleware with One HTTP Header
TL;DR
Setting a single HTTP request header on an inbound request caused Next.js to skip its middleware pipeline entirely. Any authorization check, redirect, header rewrite, or feature gate implemented in middleware.ts could be bypassed by an unauthenticated attacker. The vulnerability affects Next.js 11.1.4 through 15.2.2 and is patched in 15.2.3, 14.2.25, 13.5.9, and 12.3.5. CVSS 9.1. Self-hosted deployments were the directly exposed surface; Vercel-hosted apps were not affected because Next.js routing runs in a decoupled platform layer, not in the Node.js process the bypass would have hit. The bypass itself is one header. The interesting part is the design choice that produced it.
Credit. This vulnerability was discovered and responsibly disclosed by [Rachid Allam (@zhero__)](https://x.com/zhero_) and [Yassir Alam (@inzo)](https://x.com/inzo_). Everything that follows is my own analysis of their work and the published patch. None of the discovery is mine.
Why Middleware Is a Popular Place to Put Authorization
Next.js middleware runs on every matching request before the request reaches a route handler. It can rewrite, redirect, set headers, or return a response outright. That makes it a tempting place to enforce authorization: a single chokepoint that protects every route under it, with the auth decision made before any business logic runs.
Vercel’s own documentation has historically shown authentication checks in middleware as a recommended pattern, and a large amount of Next.js code in production follows that pattern. The implication of CVE-2025-29927 is that, prior to the patch, every one of those checks was conditional on the framework actually executing the middleware in the first place. The framework executed it conditionally, and the condition was attacker-controlled.
The Recursion Guard
To understand the bug, it helps to look at why the vulnerable header existed at all.
Middleware can perform internal subrequests. A common case is rewriting one route into another, or fetching internal data through the framework’s own routing layer. Without protection, a middleware that rewrites /foo to /bar would itself trigger middleware on /bar, and a poorly-written rewrite rule could loop indefinitely.
The Next.js maintainers solved this with a request header named x-middleware-subrequest. When middleware issued an internal subrequest, the framework attached this header to the inner request. On entering the middleware pipeline, Next.js inspected the header and, if it indicated the request was already nested deeper than a configured limit, skipped middleware execution to break the recursion.
This is a reasonable mechanism for an internal protocol. The mistake was trusting the same header on requests that originated outside the framework.
The Bug
The vulnerable check lived in the middleware dispatch path. In simplified form, the logic read the x-middleware-subrequest header on the inbound request, split the value on : into an array of middleware path segments, and compared the array length to a constant named MAX_RECURSION_DEPTH, set to 5. If the depth met or exceeded the threshold, Next.js concluded that the request was already deep inside middleware execution and skipped middleware on this request.
There was no distinction between a request that originated inside the framework and one that arrived from a remote client. The header was treated as authoritative regardless of source. A remote attacker could set the header to a value that hit the recursion limit on the first request, and the framework would obligingly skip its own middleware.
For a Next.js 15.x application with middleware.ts at the project root, the bypass value is:
x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware
Five colon-separated copies of the middleware path. If the application keeps middleware under src/, the segment changes accordingly:
x-middleware-subrequest: src/middleware:src/middleware:src/middleware:src/middleware:src/middleware
In older release lines (12.x and 13.x with the legacy pages-based middleware layout), the segment names differ. The mechanic is the same: split on :, compare length, skip on hit.
The vulnerability class is direct: a control-plane decision made from a request-scoped value an attacker controls. It is the same family of mistake as trusting X-Forwarded-For for rate-limiting, or trusting X-Real-IP for geolocation gating, with the difference that the consequence here is “skip authorization entirely” rather than “lie about the source.”
Walking the Patch
The fix landed on the main branch as PR #77201, cherry-picked to v14 in PR #77202 and to v13 as PR #77418. The 14.x and 15.x patches merged at 20:56 GMT on March 17, 2025; the 13.x and 12.x backports followed within days.
The change is narrower than “validate the inbound header” and broader than “rotate one constant.” It rebuilds the boundary between framework-internal request signaling and external traffic. Three things change.
A New Per-Process Secret
At server startup, the framework generates 8 random bytes via crypto.getRandomValues() and stores the hex-encoded value on globalThis, keyed by a Symbol.for('@next/middleware-subrequest-id'):
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
The secret lives for the lifetime of the server process. Workers within the same process share it; server restarts rotate it. Sixty-four bits of entropy is enough that an external client cannot guess the value and cannot acquire it by replaying recent traffic.
A New ID Header on Outgoing Internal Subrequests
When the framework issues an internal middleware subrequest, it now attaches the secret in a separate header, x-middleware-subrequest-id, distinct from the existing x-middleware-subrequest recursion-tracking header:
init.headers.set(
'x-middleware-subrequest-id',
(globalThis as any)[Symbol.for('@next/middleware-subrequest-id')]
)
The original x-middleware-subrequest header still tracks recursion depth in the same colon-separated form. What changed is that its presence is no longer enough to claim “this request is internal.” Trust now flows through the new ID header.
Inbound Sanitization at the Request Boundary
The router server pipes every inbound request through a new filterInternalHeaders() step before middleware runs:
if (!process.env.NEXT_PRIVATE_TEST_HEADERS) {
filterInternalHeaders(req.headers)
}
That function (in packages/next/src/server/lib/server-ipc/utils.ts) deletes a list of internal-protocol headers from any inbound request whose x-middleware-subrequest-id does not match the server’s secret. The filtered list is broader than the one the CVE exploited:
x-middleware-rewritex-middleware-redirectx-middleware-set-cookiex-middleware-skipx-middleware-override-headersx-matched-path
And, critically, x-middleware-subrequest itself.
By the time middleware dispatch reads x-middleware-subrequest on an inbound request, the header has already been stripped if the request did not carry the correct ID. The recursion-skip branch therefore sees no header value, and middleware runs normally. Internal subrequests still pass through the same filter, but they carry the secret in x-middleware-subrequest-id and so are recognized and left intact.
What the Diff Tells You
Two things are worth pulling out of this design.
First, the fix did not add input validation in the usual sense. It removed the implicit trust by making the legitimate marker unforgeable from outside the process. The recursion-skip logic in middleware dispatch was not changed at all; it now just sees a sanitized header-set.
Second, the scope of the filter is broader than the single bug. The patch took the whole class of “internal-protocol headers visible at the request boundary” and built one sanitization step for all of them. If a future Next.js bug were to depend on x-middleware-rewrite or x-matched-path from outside, that bug would not be exploitable as long as the inbound request did not carry the matching ID. The CVE-2025-29927 fix is also a partial mitigation against bugs that have not been written yet.
There is a subtle implication for testing. The NEXT_PRIVATE_TEST_HEADERS environment variable lets test harnesses bypass the filter so they can drive middleware behavior directly. Setting that variable in production would re-enable the original class of bug.
Demonstrating the Bypass
The shape of an exploit request is straightforward. Against a vulnerable Next.js 15.x application that gates /admin with middleware, a normal unauthenticated request returns a 401:
GET /admin HTTP/1.1
Host: target.example.com
HTTP/1.1 401 Unauthorized
Content-Type: application/json
{"error": "Unauthenticated"}
The same request with the bypass header returns the protected content:
GET /admin HTTP/1.1
Host: target.example.com
x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware
HTTP/1.1 200 OK
Content-Type: text/html
<!doctype html>
<html>
<body>... admin content ...</body>
</html>
No authentication, no session cookie, no token. The middleware did not run, so the auth check the middleware contained did not run.
The same pattern bypasses every other check that lived in middleware: role checks, geo-blocks, header normalization that strips dangerous headers, custom CSRF gates, feature flags, and host header enforcement. Anything implemented as a guard in middleware.ts is reachable past.
Impact
The blast radius depends on what the application delegated to middleware. Three patterns produced the worst outcomes in disclosed reports.
The first is full authorization. Applications that gated all /admin/* or /api/internal/* routes through middleware were directly exposed: an unauthenticated attacker could reach the protected route handlers and any logic behind them.
The second is partial authorization combined with header-based identity. Applications that used middleware to set or strip headers (for example, removing a forwarded-user header before it reached the route handler) leaked an identity-injection primitive. With middleware skipped, an attacker-controlled header reached the handler intact and could be trusted.
The third is rate limiting and abuse controls. With middleware bypassed, any cap intended to prevent automation or credential stuffing was no longer in effect on the protected paths.
The exposure was not symmetric across deployment models. Self-hosted Next.js applications running with next start or output: 'standalone' were the directly affected surface. Vercel-hosted deployments were not vulnerable, because the Vercel platform’s routing and middleware execution runs in a separate distributed system, not the same Node.js process that the bypass would have reached. Vercel’s own postmortem describes this as an architectural rather than mitigation-driven outcome, and notes that an early Firewall-flavored changelog they published was confusingly worded and gave the wrong impression about how protection was achieved. The practical takeaway: if you self-host Next.js, you needed to patch.
Remediation
The direct fix is to upgrade to a patched release on the line in use:
- 15.x: 15.2.3 or later
- 14.x: 14.2.25 or later
- 13.x: 13.5.9 or later
- 12.x: 12.3.5 or later
Versions older than 12.3.5 are unsupported and should be migrated.
For deployments that cannot upgrade immediately, stripping or rejecting the x-middleware-subrequest header at the edge (CDN, reverse proxy, ingress) is a workable interim control. The header has no legitimate inbound use, so stripping it does not break anything.
The longer-term takeaway is harder. Authorization that lives only in middleware is brittle by construction: it depends on a framework component executing as expected on every request, and any framework bug that skips that component skips the auth check with it. Authorization decisions for sensitive routes should be enforced as close to the data access path as possible, not only at the framework boundary. Treat middleware-level checks as defense in depth, not as the primary control.
For detection, log the presence of x-middleware-subrequest on inbound traffic to a Next.js application. A non-zero count of those requests after the patch is deployed is interesting. Before the patch, it was a high-confidence indicator of exploitation attempts.
The Lesson That Generalizes
The class of mistake is “trusting an internal-protocol header on the request boundary.” It recurs in different shapes across the stack:
- HTTP request smuggling primitives that rely on a frontend trusting
Transfer-EncodingorContent-Lengthdifferently than the backend. - Cloud runtime metadata channels where headers like
Lambda-Runtime-Aws-Request-Iddistinguish internal callers, and any path that reaches the runtime from outside the expected boundary can imitate them. - CDN-trusted fields like
X-Forwarded-ForandCf-Connecting-IPused by origin servers as authoritative client identity. - Reverse-proxy authentication patterns where an upstream sets
X-Authenticated-Userand the downstream trusts it without checking who set it.
The actionable rule is consistent: any header used to make an authorization or routing decision must be either stripped at the trust boundary or cryptographically bound to the request. Naming a header to look internal is not a control. Documentation that says “this is for internal use only” is not a control. The Next.js fix is a clean illustration of what “cryptographically bound” looks like in practice: the value the framework expects is a per-server-lifetime secret the attacker cannot produce.
Disclosure Timeline
2025-02-27 06:03 GMT Reported via GitHub private vulnerability reporting
2025-03-01 02:00 GMT Follow-up email extending the affected scope
2025-03-05 10:38 GMT Vercel security team responded
2025-03-14 17:18 GMT Vercel engineering confirmed the vulnerability
2025-03-17 17:54 GMT Patch PR opened on vercel/next.js
2025-03-17 20:56 GMT Patch merged
2025-03-17 22:44 GMT Next.js 14.2.25 released
2025-03-18 00:23 GMT Next.js 15.2.3 released
2025-03-18 18:03 GMT CVE-2025-29927 issued
2025-03-21 10:17 GMT Public disclosure
2025-03-22 21:21 GMT Next.js 13.5.9 backport released
2025-03-23 06:44 GMT Next.js 12.3.5 backport released
References
- Next.js / Vercel postmortem: Postmortem on Next.js Middleware bypass
- GitHub Security Advisory: GHSA-f82v-jwr5-mffw, Authorization Bypass in Next.js Middleware
- NVD entry: CVE-2025-29927
- Patched releases: v15.2.3, v14.2.25, v13.5.9, v12.3.5
- Patch PRs: #77201 (main), #77202 (v14 backport), #77418 (v13 backport)
- Original researcher writeup: Next.js and the corrupt middleware: the authorizing artifact (zhero_web_security)
- Vercel Firewall changelog: Protection against Next.js CVE-2025-29927 (the postmortem above acknowledges this changelog’s wording was misleading)
- Independent technical analyses worth reading: