A Header You Were Never Supposed to Send: Detecting the Next.js Middleware Bypass
CVE-2025-29927 lets an external request skip Next.js middleware entirely — and with it every auth check built there — by sending one header the framework only ever meant to talk to itself. Here's the bug, and the Metasploit scanner I wrote to detect it without touching the data behind the gate.
Most authorization bugs are a missing check. This one is the opposite: the check is there, it works, and you can ask the framework to skip it by sending a header it only ever meant to say to itself.
That’s CVE-2025-29927, the Next.js middleware authorization bypass found by Rachid Allam and Yasser Allam. I wrote the Metasploit detection module for it — PR #21566 — and unlike the LiteLLM SQLi module, the interesting part here isn’t the framework plumbing. It’s that the whole detection comes down to one careful comparison, and getting that comparison wrong would quietly miss real bypasses.
The bug: a header the framework talks to itself with
Next.js middleware is where a lot of apps put their front door — auth checks, redirects to a login page, role gates. It runs before the route handler, and it’s the natural place to say “not logged in? go to /login.”
Internally, middleware can trigger subrequests, and Next.js needs to avoid running middleware on its own internal calls — otherwise you’d get infinite recursion. So it tags those internal subrequests with a header, x-middleware-subrequest, and when it sees that header, it skips the middleware. Reasonable design.
The flaw is what it doesn’t do: verify the header came from inside. Next.js trusts x-middleware-subrequest without checking that it originated internally. So an external client that simply sends the header causes middleware to be skipped entirely — and with it every auth check, authorization rule, and redirect implemented there. The gate doesn’t fail; it’s never consulted. Affected self-hosted versions are < 12.3.5, < 13.5.9, < 14.2.25, and < 15.2.3.
One bug, several payloads
You’d think the header value would be a constant, but Next.js changed how it matches over its release history, so a scanner that sends one value will miss real targets. The module carries a small list:
PAYLOADS = [
'middleware:middleware:middleware:middleware:middleware',
'src/middleware:src/middleware:src/middleware:src/middleware:src/middleware',
'middleware',
'src/middleware',
'pages/_middleware'
].freeze
The repetition isn’t superstition. Around Next.js 13.2 the skip logic was changed to only trigger once the middleware module name appears five times — a MAX_RECURSION_DEPTH constant — so newer targets need middleware:middleware:middleware:middleware:middleware. Earlier versions accept a single occurrence. The src/ variants cover apps whose middleware lives under a src/ directory, and pages/_middleware covers the old pages-router layout. Five payloads, one for each shape the framework has worn.
The hard part: knowing a bypass when you see one
This is detection only — the module confirms the gate can be skipped and stops there. It never reads or acts on whatever sits behind the gate. For an authz bypass, the honest way to do that is a differential: see what the protected path does normally, then see what it does with the header, and decide whether the gate disappeared.
The baseline is a plain request to a path that’s supposed to be gated — an authenticated route that redirects to login, or returns 401/403:
GATE_CODES = [301, 302, 303, 307, 308, 401, 403].freeze
If the baseline isn’t one of those, the module says so and tells you to point TARGETURI at an actually-protected path — a scanner that “finds nothing” on an ungated URL is worse than useless, because it reads as safe.
Then it replays the request with each payload and asks: did the gate go away? My first instinct was the obvious test — “is the response now a 200?” That’s necessary but not sufficient, and this is the detail I’m glad I caught. Consider a target whose middleware redirects /dashboard → /login. Bypass the middleware and you might still get a redirect — but to /dashboard/, the framework’s own trailing-slash normalization, not the login page. Same status code, completely different meaning. A “is it 200 now?” check sails right past it.
So the module compares two things, the status class and the Location:
gate_gone = !GATE_CODES.include?(res.code) && res.code != baseline.code
redirect_changed = GATE_CODES.include?(res.code) &&
(res.code != baseline.code || res.headers['location'].to_s != base_loc)
return { payload: payload, response: res } if gate_gone || redirect_changed
gate_gone is the clean case — the protected page is served. redirect_changed is the subtle one — it’s still a redirect, but no longer to the login target, which means the middleware’s redirect is gone even though the status didn’t change. Comparing the Location is what keeps a same-status bypass from slipping through. Responses ≥ 500 are skipped, because a server erroring out isn’t a bypass.
When something matches, the verdict carries the evidence — the before, the after, and the payload that did it:
Middleware bypassed: HTTP 307 -> /login -> HTTP 200 with 'middleware:middleware:middleware:middleware:middleware'
Fingerprinting is a hint, not the verdict
The module does try to fingerprint Next.js — X-Powered-By, x-nextjs-* headers, /_next/static/ in the body — but only for reporting. The differential is the authoritative signal. A proxy in front of the app can strip the X-Powered-By header, and if I gated the verdict on the fingerprint, that proxy would hide a real vulnerability. So the fingerprint decorates the output; it never decides it.
The review: one comment, and it was about a hash
If the LiteLLM module was six review threads collapsing into one architectural rebuild, this one was the opposite — a single comment, and a small one. jheysel-r7 asked:
Could you please change the return value from an array to a hash? That would follow the metasploit convention more closely.
My bypassing_payload had returned a [payload, response] array. The fix (commit 14643e543c) was to return { payload:, response: } instead, and update the two call sites to use the keys — which is why the snippets above read hit[:payload] and hit[:response].
It’s a tiny change, and I’m including it precisely because it’s tiny. A named hash key tells the next reader what hit[:response] is; hit[1] makes them go count tuple positions. Conventions like this are the unglamorous tax that keeps a framework with thousands of modules readable by people who didn’t write any given one. The reviewer wasn’t fixing a bug. He was keeping the module legible to the next person — and that’s a real part of what “shipping upstream” means.
What this one taught me
The LiteLLM module’s lesson was reach for the framework’s machinery. This one’s lesson is narrower and sharper: in a differential check, define “different” carefully, because the gap is where the false negatives live. The naive 200-check would have passed every demo I’d have thought to run and still missed the trailing-slash-redirect bypass in the field. The bug was easy to understand; building a detector that doesn’t lie about a patched-looking host was the actual work.
It also sits in a quiet category I keep running into: vulnerabilities that aren’t a coding mistake so much as a trust boundary drawn in the wrong place. The header wasn’t a bug. Trusting it from outside was. Detection, fittingly, is just the discipline of not trusting the easy signal either — not the fingerprint, not the status code alone — and confirming the gate is really, provably gone.
Newsletter
Liked this? Get the next one.
One essay or short note every other week — privacy-first software, AI, security, and the occasional dispatch from the trail. No filler.
More writing
Writing a Metasploit Module for a Pre-Auth SQLi in an LLM Gateway
How I turned CVE-2026-42208 — a time-based blind SQL injection in LiteLLM's proxy — into a benign, lab-verified Metasploit detection module, and what the Rapid7 review cycle taught me about shipping upstream.
ReadAttacking the AI Stack: Teaching garak to Smuggle Exploits Through a Model
The LiteLLM scanner attacked the gateway. These two garak probes attack the layer above it — getting the model itself to hand you a shell command or a Mongo operator, on the bet that something downstream will run it. Here's how the probes work, and why the detectors are the hard part.
ReadI Pentested My Own Ask Bot
I put the 'Ask Me' bot on this site through a real security pass — prompt injection, jailbreaks, input fuzzing, and an automated LLM scanner from a Kali box. Here's what held, what surprised me, and the one latent bug I found.
Read