§ AC

The Next.js Middleware Bypass Is a Lesson in Where You Put Your Authorization

A single HTTP request header turned off authorization for a large slice of the Next.js install base. Not a memory corruption chain, not a clever deserialization gadget. A header named x-middleware-subrequest that, when present with the right value, told the framework “this request already passed through middleware, don’t run it again.” If your auth check lived in that middleware — and the framework’s own docs nudged you toward putting it there — then the request walked straight past it. That’s CVE-2025-29927, and the reason it’s still worth talking about in 2026 isn’t the patch. The patch is easy. It’s that a lot of shops discovered they’d staked their entire access-control story on a layer that was never designed to be the last line of defense.

The mechanism is almost embarrassingly simple, which is exactly why it’s a good teaching case. Next.js middleware can issue subrequests as part of its own work, and the framework needs a way to avoid recursing forever when that happens. The guard it chose was the x-middleware-subrequest header: if middleware sees that header carrying a value it recognizes as “this is an internal subrequest,” it short-circuits and skips execution. The header is supposed to be internal-only, set and consumed inside the runtime. Nothing stripped it on the way in from the public internet. So an external client could set it themselves, the runtime would believe the request had already been processed, and your middleware — auth, redirects, header injection, all of it — never ran.

How the value actually works, because it matters for detection

The recognized value changed across versions, and that detail decides whether you detect on presence or on content. In older releases the value was a simple string like middleware or src/middleware matching the middleware file path. In the 12.2-through-15 lineage the check shifted to a depth-based scheme: the header is split on colons, and the runtime compares the repeated segment count against an internal recursion limit (MAX_RECURSION_DEPTH, which sat at 5). So a value of middleware:middleware:middleware:middleware:middleware would satisfy the guard on a vulnerable build.

Don’t detect on the value. Detect on the header existing at all from an external source.

The reason is operational: an attacker probing your fleet doesn’t know your exact patch level, so they’ll spray several value formats to cover the version spread. A signature pinned to one value misses the others and misses the reconnaissance entirely. The header name is fixed across every variant. That’s your stable anchor. Any inbound request from an internet client carrying x-middleware-subrequest is anomalous regardless of what value rides with it, because legitimate external clients have no reason to ever set a framework-internal header.

Fixed in 14.2.25 and 15.2.3 (and backports for the 12 and 13 branches). If you’re self-hosting with output: 'standalone', you own the patching. If you’re on Vercel, the platform stripped the header at the edge and patched the runtime, so the exposure window there was narrower — but “narrower” is not “never,” and you should not assume your CDN did you any favors unless you can point at the rule that did it.

What the detection looks like in a real pipeline

Here’s the part most writeups skip: you probably aren’t logging this header today, and that’s the actual work.

Request headers are not in your access logs by default. Nginx logs the request line, status, user agent, referer — not arbitrary headers. ALB access logs don’t capture custom request headers at all. CloudFront won’t give you request headers unless you stand up a real-time log configuration or push it through a Lambda@Edge / CloudFront Function. So step one is making the field exist before you can ever alert on it.

If you terminate at nginx in front of the Next.js process, add the header to your log format:

log_format main '$remote_addr $request_method $request_uri '
                '$status $http_x_middleware_subrequest';

That $http_x_middleware_subrequest variable resolves to the inbound header value, empty string if absent. Ship that to Splunk via the HEC or the universal forwarder, or into the Elastic equivalent through Filebeat with an nginx ingest pipeline. The Elastic path is messier here because the stock nginx module won’t have a field for your custom header, so you’re writing a grok or dissect processor in the ingest pipeline and praying the field mapping doesn’t get auto-detected as something useless like a date.

Once the field lands, the Splunk search is boring in the best way:

index=web sourcetype=nginx:access x_middleware_subrequest=*
| where x_middleware_subrequest!=""
| stats count, values(uri) by src_ip, status

The expected baseline volume is zero. This is one of the rare detections where a nonzero count is the alert — no statistical threshold, no baselining window, no 47-events-in-5-minutes tuning. Presence is the signal.

Where the false positives come from. Not from the header appearing in normal traffic, because it doesn’t. They come from your own infrastructure replaying or forwarding it. Synthetic monitoring tools that capture and replay full request headers from a recorded session. A misconfigured reverse proxy in a chain that passes the header through instead of stripping it. Internal security scanners — your own DAST run will trip this the moment someone points it at the app, and that’s the first thing to carve out, by source IP, or you’ll page the on-call over your own Tenable job. After that carve-out the rule is quiet. If it isn’t quiet, something in your proxy layer is leaking an internal header outbound and you’ve found a second problem.

The gap nobody mentions: this only works if the header survives to the place you’re logging. If you’re logging at the origin nginx but your CDN already stripped the header (good) or already passed an attack through and rewrote it, your origin view is incomplete. Log as close to the edge as you can stomach the volume for. Header-level logging at the CDN is not free at scale, and that’s a real retention-cost conversation, not a checkbox.

The architecture problem, which is the actual finding

Patch the runtime and the header bypass is dead. But if the only thing standing between an anonymous request and your admin routes was a middleware if (!session) redirect('/login'), you had a single point of authorization failure, and CVE-2025-29927 just happened to be the bug that exposed it. The next framework-level bypass — and there will be one — finds the same brittle design waiting.

Middleware in Next.js runs at the edge, before the request reaches your route handlers. It’s great for redirects, locale routing, setting headers, cheap gating. It is a filter, not a vault. Authorization decisions that actually matter — can this user read this record, can this token hit this mutation — belong in the data-access layer where the request can’t route around them. The bypass is survivable when your route handlers and server actions re-check authorization against the session themselves. It’s catastrophic when middleware was load-bearing and everything downstream trusted that it ran.

That’s the lesson to carry out of this CVE into your design reviews: ask where the authorization decision is enforced, and whether anything downstream assumes an upstream layer already made it. If the answer is “the middleware handles that,” you have the same exposure under a different CVE number.

Control mapping

This lands across more families than a header bug has any right to, which tells you it’s really an architecture finding wearing a vulnerability costume.

Control Why it applies
AC-3, AC-6 Authorization enforcement bypassed; least-privilege boundaries assumed but not enforced downstream
SC-7 Boundary protection failed to strip an internal-only header at the trust boundary
SI-4 The detection itself — monitoring inbound requests for the anomalous header
CM-7 Least functionality: framework feature (subrequest guard) exposed an unintended control path
SA-11 Developer testing should have caught authorization that routes around its own enforcement layer
RA-5 Vulnerability scanning has to actually reach standalone-output deployments, which dependency scanners often miss because the version is baked into a container image, not a manifest your scanner reads

That RA-5 row is where shops get caught flat. If your SCA tool reads package.json and package-lock.json in the repo, fine. If your production artifact is a multi-stage Docker build that pins Next.js inside a layer and your scanner only looks at the running container’s OS packages, the vulnerable framework version is invisible to your vuln management. Grep your build manifests, not just your repos, and confirm the version that actually shipped.

The header detection buys you visibility into exploitation attempts. The patch closes this specific door. Neither one fixes a design that trusted a request had passed through a layer it could be told to skip. Find those before the next bypass does it for you.

Sources