← HomeBlog

2026-04-28 · Application security

After CVE-2025-29927: the Next.js auth patterns that survived the patch

We built this site on Next.js. CVE-2025-29927 was already public knowledge when we started, so before we shipped anything we sat down with the advisory and asked a specific question: what does our middleware actually protect, and what does it not? This post is the result of walking through that question carefully. The CVE is patched. The three patterns that made it critical are still the default in most Next.js codebases.

What the CVE actually did

Next.js middleware runs before every matched request reaches a route handler or page. To prevent middleware from triggering itself infinitely during internal rewrites, Next.js tracked recursion depth using an internal HTTP header called x-middleware-subrequest. When that header's value contained the middleware path five or more times (the constant MAX_RECURSION_DEPTH), Next.js stopped executing middleware and forwarded the request directly to the route.

The framework never verified whether the header came from an internal process or an external client. An attacker could include it on any request they sent to the server:

# Works against unpatched Next.js on any protected route.
# For projects using the src/ directory:
curl -H "x-middleware-subrequest: src/middleware:src/middleware:src/middleware:src/middleware:src/middleware" \
  https://example.com/admin

# For projects with middleware.ts at the root:
curl -H "x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware" \
  https://example.com/admin

Middleware was skipped entirely. Any redirect to /login, any token check, any role assertion in middleware did not run. The route handler received the request as if authentication had already happened.

CVSS score: 9.1. The fix, released in 15.2.3, 14.2.25, 13.5.9, and 12.3.5, was to generate a cryptographically random server-side token at startup and require a matching x-middleware-subrequest-id header alongside any x-middleware-subrequest header. Without the matching token, the framework strips the header before it reaches the recursion check. An external client cannot know the token.

Why the patch fixes the CVE but not the underlying risk

The patch correctly removes the exploit path. But the reason the CVE was rated 9.1 in the first place was not the header trick itself. It was that skipping middleware meant skipping auth entirely, because for a large number of Next.js applications, middleware is the only place auth is enforced.

Vercel's own documentation and the tutorials for every major Next.js auth library (Auth.js, Clerk, Supabase Auth) teach middleware as the natural home for authentication guards. The pattern looks like this:

// middleware.ts (Next.js 13-15) | proxy.ts (Next.js 16+)
// Auth.js example. Clerk, Supabase Auth, and others follow the same pattern.
import { auth } from './auth';

export default auth((req) => {
  if (!req.auth) {
    return Response.redirect(new URL('/login', req.url));
  }
});

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
// app/dashboard/page.tsx
export default async function DashboardPage() {
  // No auth check here. Middleware "already handled it."
  const data = await db.query('SELECT * FROM user_data WHERE ...');
  return <Dashboard data={data} />;
}

The route handler trusts that middleware ran and validated the request. In a patched Next.js version with no bugs, that trust is reasonable for the specific header exploit. It is not reasonable in three other situations that are alive in every codebase right now.

Pattern 1: the middleware matcher leaves routes uncovered

Middleware only runs against routes that match its config.matcher. The default example in the Next.js docs excludes _next/static, _next/image, and a few other framework paths. That is fine. The problem is when teams write tighter matchers for performance reasons:

// A common pattern: only run middleware on dashboard routes
export const config = {
  matcher: ['/dashboard/:path*', '/settings/:path*'],
};

Three months later a developer adds /app/admin/page.tsx. It never appears in the matcher. Middleware never runs for it. The route handler has no independent auth check because "middleware handles that." The page is publicly accessible with no indication in the code that anything is wrong.

This does not require a CVE. It is a configuration drift problem that appears in normal development. The longer the team works on the codebase, the more likely there is a route that was added after the matcher was written.

How to audit for this

# List every route in your app directory
find src/app -name "page.tsx" -o -name "route.ts" | sort

# Then compare against your middleware matcher manually.
# Any route not matched by the pattern will never see middleware.

# For API routes specifically, check if your matcher covers /api/*:
# The Auth.js middleware guide ships this exact pattern, and it is
# widely copy-pasted into production apps:
# '/((?!api|_next/static|_next/image|favicon.ico).*)'
#              ^^^
# It excludes /api. Every route under /api bypasses your middleware.

That last point is worth re-reading. This exact regex appears in the Auth.js session-protection guide and is widely copy-pasted into production apps. It excludes api, which means your API routes silently bypass middleware. If you relied on middleware for API auth and used this pattern, those routes are not covered.

Notably, the same Auth.js guide includes this warning directly below the matcher example: "You should not rely on the middleware exclusively for authorization. Always ensure that the session is verified as close to your data fetching as possible." That is the same point this post makes. The guidance exists in the library docs. It is easy to miss when you are focused on getting auth working and the middleware pattern is the first thing in the guide.

Pattern 2: functions that mutate data with no independent auth check

Middleware answers one question: is there a valid session for this request? It does not answer a second, equally important question: is this session allowed to act on this specific resource? When auth thinking is concentrated in middleware, both questions tend to get collapsed into one, and route handlers and Server Actions get written as if they are implicitly scoped to the requesting user.

Server Actions make this concrete. They are POST requests to the page URL with a Next-Action header identifying the function to call. They do go through middleware. But any authenticated user can observe the action identifier in their browser's network tab and call the action directly with a crafted POST, supplying whatever parameters they choose. If the action accepts a resource identifier as a parameter and does not verify ownership, the caller can act on resources that belong to other users:

// app/settings/actions.ts
'use server';

export async function deleteAccount(userId: string) {
  // Middleware confirmed the caller has a session.
  // Nothing here confirms the session belongs to userId.
  // Any authenticated user can pass a different userId.
  await db.users.delete({ where: { id: userId } });
}

This is not a Server Actions-specific problem and it is not a new problem. The same gap exists in any route handler that trusts a request parameter as an authorization token. What makes it systematic in Next.js codebases is the mental model: middleware "handles auth," so the action feels like protected code. It is not. Middleware handled authentication. Authorization of the specific operation is still the action's responsibility.

The fix: two checks, not one

// app/settings/actions.ts
'use server';

import { auth } from '@/auth';

export async function deleteAccount(userId: string) {
  const session = await auth();

  // Check 1 (authentication): is there a valid session at all?
  // This makes the action safe even if the middleware matcher
  // has a gap or the action is called from a background job.
  if (!session?.user?.id) {
    throw new Error('Unauthorized');
  }

  // Check 2 (authorization): does this session own this resource?
  // Middleware never asked this question. The action must.
  if (session.user.id !== userId) {
    throw new Error('Forbidden');
  }

  await db.users.delete({ where: { id: userId } });
}

The same two-check pattern applies to every route handler that reads or mutates data tied to a specific user or tenant. Middleware still provides the fast browser redirect. The handler enforces both authentication and authorization independently.

Pattern 3: assuming Vercel's automatic protection covers self-hosted deployments

The official CVE advisory includes a notable line: "Next.js deployments hosted on Vercel are automatically protected against this vulnerability." Vercel stripped the x-middleware-subrequest header at the edge before requests reached the Next.js runtime.

That is a useful defense-in-depth layer from Vercel. It is not a substitute for patching, and it created a specific risk: teams that test on Vercel (where the header is stripped) and deploy a second environment self-hosted (Docker, Fly.io, Railway, your own VPS) may see different behavior. The CVE did not affect their Vercel environment. It did affect their staging or on-premise environment that accepted the header.

The same logic applies to any edge or WAF rule added as a workaround. A Cloudflare WAF rule blocking x-middleware-subrequest protects the Cloudflare-fronted domain. It does not protect a staging environment that points directly to the origin, a developer's local port 3000, or an internal deployment reached via VPN that bypasses Cloudflare.

How to verify your actual running Next.js version

# Check what is actually deployed, not just what is in package.json.
# Node:
node -e "const p = require('./node_modules/next/package.json'); console.log(p.version);"

# Or from a running container:
docker exec <container> node -e "console.log(require('/app/node_modules/next/package.json').version)"

# The patched versions are: 12.3.5, 13.5.9, 14.2.25, 15.2.3, and anything newer.

What the correct architecture looks like

Middleware is genuinely useful. On Vercel it runs at the edge; on a self-hosted Node.js server it runs in the same process before route handlers. Either way it is fast and it is a good place to handle redirects, locale detection, feature flags, and security headers. The mistake is treating it as the only layer that enforces auth.

A layered approach that holds:

Limitations

Further reading