Back to blog

CVE-2025-29927: how one internal header let attackers walk straight past Next.js middleware auth

· · 18 min read
CVE-2025-29927: how one internal header let attackers walk straight past Next.js middleware auth

On March 21, 2025, Vercel published the advisory for a flaw that the security researchers Rachid Allam (zhero) and Yassir Alam (inzo_) had reported privately three weeks earlier. The verdict is short and unpleasant: if your Next.js application enforces authentication or authorization inside middleware, an unauthenticated attacker can add a single request header and have the framework skip that middleware entirely, reaching the protected route as if the gate were never there. This is CVE-2025-29927, scored CVSS 9.1 (Critical), and the striking part is not the score. It is the reach. Next.js sits under a very large slice of the modern web, the vulnerable code path existed from version 11.1.4 all the way to 15.2.2, and the exploit fits in one line: a header named x-middleware-subrequest that was only ever meant to be spoken between Next.js and itself.

This article is a two-layered breakdown. The first part is for people who need to decide quickly whether they have anything to fix and what exactly that is, in plain language, without reading a single line of framework source. The second part dissects the bug the way the researchers reconstructed it: where the header is read, why the check was there in the first place, how the value changed across major versions, and how you turn "the framework trusts a header" into "I am now inside /admin as nobody." The article closes by looking at how the Pragma Core platform addresses exactly the kind of problem that made this possible, because for a dynamic AppSec team the lesson here is not "patch Next.js." It is "find every place where a trust boundary can be talked around."


Part I. Executive breakdown

What happened

Next.js middleware is the piece of code that runs before a request reaches a page or an API route. It is where a lot of teams put their front door logic: check the session cookie, verify the user is logged in, confirm the role is allowed to see /dashboard/admin, redirect everyone else to the login screen. It is convenient precisely because it runs on the way in, in front of everything.

The framework also talks to itself. Internally, Next.js sometimes needs one middleware invocation to trigger another without spiralling into an infinite loop, so it tags those internal calls with a header, x-middleware-subrequest, and when it sees that tag it says "this is me calling myself, do not run the middleware again, just pass the request through." That is a reasonable internal optimization. The problem is that the framework never checked whether the tag actually came from itself. It read the header straight off the incoming request.

So an outside attacker can simply attach the header. Next.js sees the tag, concludes "this is an internal subrequest," and skips the middleware. Every check that lived in that middleware, the login check, the role check, the redirect, is bypassed in one move. The attacker lands on the protected route as an unauthenticated request. Nothing crashes, nothing looks broken. The gate just quietly decides not to run.

Who is affected

Component Status
Next.js 15.x before 15.2.3 Vulnerable. Upgrade to 15.2.3 or later.
Next.js 14.x before 14.2.25 Vulnerable. Upgrade to 14.2.25 or later.
Next.js 13.x before 13.5.9 Vulnerable. Upgrade to 13.5.9 or later.
Next.js 12.x before 12.3.5 Vulnerable. Upgrade to 12.3.5 or later.
Next.js 11.1.4 through 13.5.6 (no backport) Vulnerable. Apply the header block at the edge.
Apps not using middleware for authorization Exposed to bypass, but no auth to bypass
Next.js on Vercel's managed platform Mitigated at the edge by the provider

The number that should worry you is not a percentage, it is a default. Middleware-based auth is one of the most commonly recommended patterns in the Next.js ecosystem, it appears in countless tutorials and starter templates, and the vulnerable code shipped for years. Within two days of disclosure a working proof of concept and a Nuclei detection template were public, which turned this from a theoretical advisory into something anyone could fire at an unpatched host. The precondition for the attacker is essentially nothing: no account, no token, no user interaction. Just the ability to send an HTTP request.

Why this matters beyond Next.js

The specific bug is a forged internal header. The general pattern is far older and far more common: a system that keeps a private channel for talking to itself, and then forgets to prove that a given message actually arrived over that private channel. The moment an internal trust signal becomes reachable from the outside, it stops being internal.

You will find this class everywhere once you look for it. Reverse proxies that trust X-Forwarded-For or X-Real-IP for access decisions. Backends that trust an X-Internal-Request: true header set by a gateway that does not strip it on the way in. Services that treat a specific User-Agent or a "health check" path as pre-authorized. Admin panels gated only on a header the load balancer is "supposed to" add. In every case the same thing is true: the boundary between "inside" and "outside" is being asserted by data that the outside controls.

Recommended actions

  1. Upgrade Next.js to the fixed release for your line: 15.2.3, 14.2.25, 13.5.9, or 12.3.5 or later. This is the real fix and everything else is a stopgap.
  2. If you cannot upgrade immediately, strip the header at the edge. Block or drop x-middleware-subrequest on inbound requests at your WAF, reverse proxy, or CDN before it reaches the application. External clients have no legitimate reason to send it.
  3. Inventory every Next.js deployment you run, including internal tools, marketing sites, and anything a contractor spun up. You cannot patch what you cannot see, and this bug does not care whether the app felt important.
  4. Audit what your middleware actually enforces. If it is the only thing standing between an anonymous request and a protected resource, treat those routes as if they were briefly public and check logs accordingly.
  5. Review access logs for the header and for anomalous unauthenticated hits on protected routes around and before your patch date. Absence of the header in logs is not proof of safety, but its presence is a strong signal.
  6. Add a defense-in-depth check at the route or data layer, so that a bypass of the front-door middleware does not equal a bypass of all authorization. The middleware should not be the only gate.

Part II. Technical breakdown

Background: what middleware is and what the subrequest header was for

Next.js middleware is a single function, conventionally exported from middleware.ts (or src/middleware.ts) at the project root, that executes in the Edge runtime before a matched request is handled. It receives the request, can read cookies and headers, and returns either NextResponse.next() to let the request continue, a redirect, or a rewrite. Because it runs in front of routing, teams lean on it to centralize cross-cutting concerns, and authentication and authorization are the most popular of those.

Under the hood, Next.js can invoke middleware as part of processing a request that itself originated from middleware logic, for example a rewrite that lands on another matched path. Without a guard, that can recurse. To prevent an internal loop, Next.js marks these self-originated invocations with the x-middleware-subrequest header, and when it processes a request that already carries the marker for a given middleware, it does not run that middleware again. It just continues.

The design invariant that should have held is simple: this header is an internal signal, so it must only ever be trusted when Next.js itself produced it. The bug is the violation of exactly that invariant.

The vulnerability: an internal loop guard read straight from the request

The relevant logic lives in the server's middleware runner. Reconstructed from the framework source, the check reads the header directly off the incoming request, splits it on colons, and if the list contains the current middleware's name, it short-circuits:

// Next.js middleware runner (paraphrased from source)
const subreq = params.request.headers['x-middleware-subrequest'];
const subrequests = typeof subreq === 'string' ? subreq.split(':') : [];

for (const middleware of this.middleware || []) {
  // ... resolve middlewareInfo for this entry ...

  if (subrequests.includes(middlewareInfo.name)) {
    // treated as an internal self-call: skip execution entirely
    result = {
      response: NextResponse.next(),  // <-- your auth code never runs
      waitUntil: Promise.resolve(),
    };
    continue;
  }

  // normal path: actually load and execute the middleware module,
  // i.e. the function where your session and role checks live
}

Read that if carefully, because the whole CVE is in it. There is no verification that the request is internal. There is no secret, no signature, no per-request nonce. The condition is purely "does an attacker-supplied string contain the middleware's name." If it does, the runner substitutes a bare NextResponse.next() and continues, and the middleware module, the one that would have checked your session and your role, is never loaded and never executed.

Root cause: the value the attacker needs is guessable

The only thing an attacker has to know is middlewareInfo.name, the identifier the runner compares against. That name is not a secret. It is derived from the middleware's conventional location, which is fixed by the framework, and it changed only slightly across major versions:

That leaves one obstacle. Starting around 13.2.0, Next.js added a maximum recursion depth so that self-calls could not nest forever. But the header check quoted above runs before that depth logic bites, and the value is split on colons into a list. So an attacker just repeats the name enough times to sail past any depth counter while still matching on includes:

GET /dashboard/admin HTTP/1.1
Host: app.example.com
x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware

Five repetitions is the value used in the public proof of concept, and it is enough to cover the depth limit in affected versions. There is no body, no cookie, no token. The precondition is "can send an HTTP request," which is why the CVSS vector is AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N: network reachable, low complexity, no privileges, no interaction, high impact on confidentiality and integrity.

Exploitation: from one header to inside the protected route

The exploit is the request above. There is no second stage in the framework itself. Send the header to a route whose protection lives in middleware, and the middleware is skipped, so the request proceeds to the underlying page or API route unauthenticated.

What that gets you depends entirely on what the middleware was carrying:

The severity in any given app is therefore a function of how much the team leaned on middleware as the one place to enforce access. Applications that also re-check authorization at the data or route-handler layer degrade gracefully. Applications that treated middleware as the single front door are wide open. That variance is the whole reason a dynamic AppSec team cannot answer "are we affected?" with a version number alone. The version tells you the gate can be skipped. Only reading the code tells you what was behind the gate.

Affected versions

The fix itself is instructive. Rather than trust an attacker-known constant, the patched runner validates x-middleware-subrequest against a randomly generated value that only the framework knows for that execution, and filters the header out when validation fails. In other words, the internal signal was made genuinely internal: unguessable, and stripped when it arrives from the wrong side.

Timeline

Date Event
2025-02-27 Rachid Allam (zhero) and Yassir Alam (inzo_) report the flaw privately via GitHub security advisory
2025-03-01 Follow-up report extends the affected scope to more recent versions
2025-03-17 Patch pull request opened and merged on the Next.js repository
2025-03-17 Next.js 14.2.25 released with the fix
2025-03-18 Next.js 15.2.3 released with the fix
2025-03-21 CVE-2025-29927 published
2025-03-23 Public proof of concept and a Nuclei detection template released

A note on the discovery methodology

This was found by reading, not by fuzzing. The researchers followed an internal header through the framework's own request handling and asked the question that off-the-shelf tooling almost never asks: what happens if this internal-only value arrives from outside? There is no memory corruption here, no exotic primitive, no payload craft. The entire bug is a mismatch between an assumption ("only I send this header") and an enforcement ("I read this header from the request"), and the only way to see it is to trace where a piece of state comes from and where it is trusted.

The meta-lesson for the AppSec community is that the highest-impact web bugs of the last few years have increasingly been logic and trust-boundary bugs in frameworks, not classic injection in application code. They live in the seams between components, and they reward whoever is willing to read the framework as carefully as they read their own app. That is a review posture, not a scanner setting.


What we should learn from CVE-2025-29927

  1. An internal trust signal is only internal if the outside cannot produce it. Any header, cookie, or parameter the system trusts to mean "this came from me" must be unguessable and must be stripped at the boundary. If it can be typed by an attacker, it is attacker input, full stop.
  2. Centralizing all authorization in one layer creates a single point of bypass. Middleware-as-the-only-gate is efficient right up until someone finds a way to make the gate not run. Re-check authorization close to the resource, so skipping the front door does not skip everything.
  3. Fail closed, not open. When the runner saw the subrequest marker it chose to skip enforcement. An unrecognized or unverifiable internal signal should deny or run the check, never silently pass the request through.
  4. Framework code is your attack surface, not just your dependency. The vulnerable line was not in application code, and no amount of reviewing your own routes would have revealed it. Teams need visibility into how the frameworks they build on handle trust, not only into their own logic.
  5. Version numbers describe exposure, not impact. Knowing you run a vulnerable Next.js tells you the gate can be skipped. Only reading what the middleware enforces, and what sits behind it, tells you what a skip actually costs you. Both questions have to be answerable quickly.

How Pragma Core addresses this class of problem

CVE-2025-29927 is the kind of issue classical scanners miss, because the flaw is not a single bad line in your code. It is about how a value flows across a trust boundary: an internal signal that the framework reads from an external request and then trusts. A taint rule tuned for SQL and shell sinks has nothing to say about it. Pragma Core is built for exactly this class, where the bug lives in the relationship between input, state, and the boundary they cross. Here is how the platform would have shortened the distance between "this CVE exists" and "here is every place we are exposed and by how much."

SAST tuned for the trust-boundary pattern, not just injection sinks

Off-the-shelf static analysis is optimized for taint flows that end in a dangerous sink. The pattern behind this CVE is different: a value read from an untrusted request is used to make a control-flow decision that skips a security check. Pragma Core's static analysis can be tuned to that shape, "request header or parameter used as the condition that bypasses an authorization or middleware step," and flag it as a high-confidence finding. In application code that mirrors this pattern, an internal X-Internal: true header trusted for access, a debug query parameter that disables checks, that same rule fires before it ships.

Interactive call graphs with a vulnerability overlay

The reason this bug is dangerous varies per application: it depends on whether middleware is the only thing guarding a route. Pragma Core auto-generates call graphs for a connected repository and overlays findings, so an AppSec team can see, visually, which protected routes have their only authorization check in middleware and which re-verify downstream. The flaw was in a relationship, "this route trusts that gate and nothing else," and a call graph makes that relationship the thing you look at, instead of hoping to notice its absence while reading files one at a time.

Autonomous AI agents for attack-chain investigation

The researchers found this by asking a single sharp question of the framework: is there any request-controlled input that makes the middleware not run? Pragma Core's autonomous agents reason over attack chains rather than stopping at a flagged line, and that is precisely the question to pose across a codebase: where does a security check depend on a condition an attacker can influence, and which higher-privilege consumer sits behind it? Asked systematically of your own gateways, feature flags, and admin gates, that question surfaces the next CVE-2025-29927-shaped bug before a researcher does.

Continuous dependency tracking with fix paths

The moment CVE-2025-29927 lands in the vulnerability catalog, every connected repository that pins an affected Next.js range is flagged automatically, with the CVSS score and the exact fixed version for its line (15.2.3, 14.2.25, 13.5.9, 12.3.5). For a dynamic AppSec team, that turns disclosure day from a frantic manual sweep into a filtered list. Next.js is exactly the kind of framework that hides in dozens of internal tools and marketing sites, and continuous tracking finds those copies whether or not anyone remembered they existed.

Full SBOM per repository

The first question in any framework CVE is "which of our apps run which version," and most teams cannot answer it fast enough. Pragma Core generates a complete per-repository inventory of libraries, versions, and package URLs, exportable as CycloneDX JSON, so "show me every deployment on a vulnerable Next.js line and its exact version" is a query, not an archaeology project. That is the difference between reporting exposure the same afternoon and finding a forgotten instance three weeks later.

DAST that confirms the bypass on the live application

Knowing a route is guarded only by middleware is exposure. Proving the forged header actually reaches the protected resource is confirmation. Pragma Core's AI-driven dynamic testing can replay the x-middleware-subrequest bypass against live routes and report confirmed findings, so the team distinguishes "theoretically affected" from "reachable and bypassable right now," and prioritizes accordingly.


Closing thoughts

CVE-2025-29927 is not, fundamentally, a bug about Next.js middleware. It is a bug about trusting a message because of the envelope it arrived in, without checking that the envelope was really yours. That pattern is everywhere in modern architectures: gateways that stamp headers the backend trusts, service meshes that assert identity through metadata, proxies that mark requests as internal. Any one of them can be talked around the instant the marker becomes reachable from the outside, and none of them show up as a red line in a conventional scan, because there is no bad line, only a boundary that was asserted with attacker-controllable data.

The difference between treating this article as a curiosity and treating it as an audit trigger comes down to AppSec maturity and code visibility. A dynamic team does not just patch the framework and move on. It asks where else the same shape lives in its own systems, and it wants the answer this week. Organizations that want to move from "we scan and report" to "we systematically investigate what is fragile" can reach Pragma Core at pragma-core.com for a demo.


Sources

Related posts
CVE-2026-74820: how an unsanitized ORDER BY clause turned ServiceNow's AI Platform into an unauthenticated database backdoor
Sep 18, 2026
CVE-2026-85978: how one path normalization mismatch turned Akana's admin console into unauthenticated remote code execution
Sep 9, 2026
CVE-2026-78174: how an unredacted session token in a diagnostic log turned a low-privileged WatchGuard Dimension admin into super admin
Sep 1, 2026

Start securing your codebase today

Connect your repositories and let AI agents handle continuous scanning, research, and triage.

Have questions? Get in touch →