On 21 March 2025, a security researcher disclosed that a single HTTP header — x-middleware-subrequest — could walk past every Next.js middleware function ever written. Most teams patched within days. Almost none asked the harder question: were the routes that middleware was protecting present in the test suite at all, or had the suite been reaching them through the middleware now proven untrustworthy?
Route-level test coverage is the measure of which application routes are independently verified by the test suite — not through a shared middleware or authentication layer, but by direct navigation and assertion per route. When middleware fails, routes without independent coverage are exposed with zero test protection.
What the vulnerability actually did
CVE-2025-29927 allowed any external HTTP client to bypass Next.js middleware entirely by spoofing a single internal header, granting unauthenticated access to every protected route in the application.
CVE-2025-29927 carried a CVSS base score of 9.1 (Critical) and an EPSS probability in the 93rd percentile — near-certainty of active exploitation at disclosure. The mechanism was embarrassingly simple. Next.js uses an internal header, x-middleware-subrequest, to prevent middleware from processing the same request recursively. The framework checks whether the header’s value matches the middleware file path. If it does, middleware execution is skipped entirely, and the request passes through to the route handler as though no middleware existed.
The header was never intended to be user-controlled. But nothing stopped an external client from setting it. An attacker who sent a request with x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware (or src/middleware repeated, depending on the project structure) could bypass every authentication check, every authorisation gate, every CSP header, and every redirect that middleware enforced. No credentials required. No user interaction. Network-accessible, low-complexity, high-impact — hence the 9.1.
The vulnerability affected every Next.js release from version 11.1.4 through the patch versions: 12.3.5, 13.5.9, 14.2.25, and 15.2.3. That is roughly four years of production deployments. Vercel-hosted applications were automatically protected at the platform level, but self-hosted instances — the majority of enterprise deployments — were fully exposed.
Why middleware-first auth creates a testing blind spot
When your test suite authenticates through middleware, it tests the gate — not the routes behind it. If middleware fails, every route the suite reached through it has zero independent coverage.
The architectural pattern the CVE exploited is familiar to every Next.js developer. You write a middleware.ts at the project root. It intercepts every request, checks the session cookie, and either allows the request through or redirects to /login. Routes behind the middleware never implement their own auth check because the middleware is the auth check.
This works in production until it doesn’t. But it fails silently in the test suite from day one.
When you write a Playwright test for /dashboard/settings, the test runner navigates to the URL, the middleware inspects the session cookie (which your test fixture has already set via storageState), and the page renders. The test passes. But what did you actually test? You tested that the middleware allowed an authenticated session through. You did not test that /dashboard/settings itself enforces any access control. You tested the gate, not the room behind it.
The CVE removed the gate. Every route behind it was exposed. And the test suite — which had only ever reached those routes via the gate — had nothing to say about it.
The “patched but untested” failure mode
Patching the CVE closes the exploit. It does not add the vulnerable routes to your test suite, verify per-route access control, or answer the question the CVE should have raised: which routes are exposed with zero independent test coverage?
Here is the sequence most teams followed. On 21 March, the advisory landed. Within 48 hours, the dependency was bumped to a patched version. CI went green. The team moved on.
What didn’t happen: nobody opened the test suite and asked which routes were covered by direct assertion versus which routes were covered only by the middleware shortcut. The patch closed the exploit. It did not add the routes to the suite. It did not verify that each protected route enforces its own access control when the middleware is absent. It did not answer the question the CVE should have raised — if middleware fails again, which routes are exposed with zero test coverage?
This is the “patched but untested” failure mode, and it is the one that persists long after the CVE is resolved. The next middleware vulnerability — in Next.js or any other framework — will find the same routes unguarded and the same test suite silent.
Constructing a route inventory
Before you can measure the test gap, you need a complete inventory of routes. In a Next.js application, routes are derived from the filesystem, which makes enumeration straightforward.
For the App Router (Next.js 13.4+):
find app -name 'page.tsx' -o -name 'page.ts' -o -name 'page.jsx' \
| sed 's|/page\.\(tsx\|ts\|jsx\)$||' \
| sed 's|^app||' \
| sort
For the Pages Router:
find pages -name '*.tsx' -o -name '*.ts' -o -name '*.jsx' \
| grep -v '_app\|_document\|_error\|api/' \
| sed 's|^pages||; s|\.\(tsx\|ts\|jsx\)$||' \
| sort
For API routes specifically:
find pages/api -name '*.ts' -o -name '*.tsx' | sort
# or, for App Router route handlers:
find app -name 'route.ts' -o -name 'route.tsx' | sort
The output is your route inventory. Every line is a path your application serves in production. The question is how many of those paths your test suite has ever visited.
Mapping the test gap
Now, extract the routes your test suite actually hits:
grep -roh "page\.goto(['\"][^'\"]*['\"])" tests/ \
| sed "s/page\.goto(['\"]//; s/['\"])$//" \
| sort -u
If you use API testing alongside Playwright:
grep -roh "request\.\(get\|post\|put\|delete\)(['\"][^'\"]*['\"])" tests/ \
| sed "s/request\.[a-z]*(['\"]//; s/['\"])$//" \
| sort -u
With both lists in hand, the diff is a single command:
comm -23 routes-inventory.txt test-visited-routes.txt
The output is every route that exists in production but has never appeared in a test. On a typical 40–60 route application, this diff surfaces between five and fifteen routes. The usual suspects: admin-only pages, post-checkout confirmation flows, error boundary routes, archived features that still have live routes, and — critically — routes that were added after the initial test suite was written and never picked up.
What a minimal post-patch test suite looks like
The patch closes the specific x-middleware-subrequest exploit. A post-patch test suite closes the category of failure the exploit revealed. The difference matters.
For each protected route in your inventory, you need two assertions:
Unauthenticated access should be denied. Navigate to the route with no session cookie. Assert a redirect to /login or a 401/403 response. This tests the route’s behaviour without middleware — which is exactly the state the CVE created.
test('unauthenticated user cannot reach /dashboard/settings', async ({ browser }) => {
const context = await browser.newContext(); // no storageState
const page = await context.newPage();
const response = await page.goto('/dashboard/settings');
// Either a redirect to login or a 4xx status
expect(
page.url().includes('/login') ||
(response && response.status() >= 400)
).toBeTruthy();
await context.close();
});
Authenticated access should succeed per role. Navigate to the route with a valid session. Assert the page renders the expected content, not a redirect or error.
test('authenticated user reaches /dashboard/settings', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/dashboard/settings');
await expect(authenticatedPage.locator('h1')).toContainText('Settings');
});
The key shift is testing each route independently of middleware, not through it. If your application’s route handlers delegate all access control to middleware, that is the structural finding — and it is worth fixing regardless of whether CVE-2025-29927 is patched.
Why even the newest tooling doesn’t close this gap automatically
In October 2025, Playwright v1.56 introduced Test Agents — a Planner that explores your application, a Generator that writes test files from the plan, and a Healer that repairs broken tests. These agents are a genuine step forward for test maintenance. But they operate on the same navigation graph your application exposes from a seed URL. If a route is not linked from any path the Planner can reach, the Planner will not discover it. The agents explore what is reachable. They do not inventory what should be reachable but isn’t.
This is not a limitation of Playwright specifically. It is a structural property of any tool that discovers routes by crawling. Crawlers follow links. Routes that exist in the filesystem but are absent from the link graph — the exact failure class CVE-2025-29927 exposed — are invisible to crawl-based discovery. Closing the gap requires a route inventory derived from the source code, compared against the routes the test suite exercises. That is a fundamentally different operation from crawling.
Security boundaries and navigation boundaries are the same boundary
Every route your application serves is a navigation boundary. When you test those routes only through the authentication layer, you are testing the boundary once and assuming it holds everywhere downstream. CVE-2025-29927 proved that assumption wrong.
The deeper lesson of CVE-2025-29927 is not about a header, or about Next.js, or even about middleware as an architectural pattern. It is about the assumption that a test suite covers a route because the suite can reach it through a working authentication layer.
Every route your application serves is a navigation boundary. Some of those boundaries are also security boundaries — they enforce who can access what. When you test those routes only through the authentication layer, you are testing the boundary condition once (at the middleware) and assuming it holds everywhere downstream. The CVE proved that assumption wrong for a specific header. But the structural risk — routes that exist, respond, and are never independently tested — predates this CVE and will outlast it.
The five-minute audit is straightforward: enumerate your routes, enumerate your test visits, diff the two lists, and start with the routes that enforce access control. The full walkthrough is in the five-minute protected-routes audit. The routes that show up in the diff are the routes that have been silently exposed since the day they were deployed. Whether the next exploit arrives via a header bypass, a misconfigured reverse proxy, or a framework upgrade that changes routing behaviour, those untested routes will be the ones that break first.
Frequently asked questions
What is CVE-2025-29927 and which Next.js versions are affected?
CVE-2025-29927 is a critical authorisation bypass vulnerability (CVSS 9.1) in Next.js middleware. It affects all versions from 11.1.4 through the patches at 12.3.5, 13.5.9, 14.2.25, and 15.2.3. An attacker could bypass middleware by setting the internal x-middleware-subrequest header in an external request.
Why didn’t standard Playwright or Cypress tests catch this vulnerability?
Most test suites authenticate via storageState or a login fixture that relies on middleware to gate access. The tests reach protected routes through the middleware, so they never verify that each route independently enforces access control. When the middleware was bypassed, the suite had nothing to report because it had never tested the routes without the middleware.
What is the difference between route-level test coverage and code coverage? Code coverage measures which lines of source code are executed during a test run. Route-level test coverage measures which application routes are independently navigated to and asserted against by the test suite. A route can have 100% code coverage (its component renders) while having zero route-level coverage (no test ever navigated to it independently of middleware).
How do I audit which routes are missing from my test suite?
Enumerate every route from your framework’s filesystem or route configuration, extract every URL your tests navigate to (grep for page.goto or cy.visit), and diff the two lists. The gap is your untested routes. The full walkthrough is in the five-minute protected-routes audit.