Logo
Published on

Why Middleware-Only Auth in Next.js Is Still Dangerous (Lessons From the CVE-2025-29927 Bypass)

Authors
security lock on a screen

What just happened

A few days ago a CVE dropped that honestly should scare anyone using Next.js middleware for authentication. The编号 is CVE-2025-29927 and it is embarrassingly simple. An attacker could bypass your middleware entirely by sending a single HTTP header called x-middleware-subrequest. That is it. No exploit chain, no fancy privilege escalation, just one header and your middleware never runs.

If your auth check lives only in middleware.ts then every protected route in your app was suddenly unprotected. API keys, admin panels, internal dashboards, everything behind middleware was open to anyone who knew about the header. And the header name was in the Next.js source code the whole time.

I am not going to pretend this is some edge case. I have seen a lot of Next.js codebases over the last two years and the pattern is everywhere: put a token check in middleware, match a few paths, call it a day. It feels secure because middleware runs before everything else. But middleware is not a security boundary. It is a convenience layer. There is a difference and this CVE is the proof.

How the bypass worked

Next.js uses the x-middleware-subrequest header internally to detect when a request is a subrequest triggered by middleware itself. The idea is that middleware should not run again on its own subrequests to avoid infinite loops. The problem was that this header was trusted without any validation. Any external request could include it and Next.js would happily skip the middleware.

Here is roughly what happened inside the server:

// Simplified version of what was happening
const subrequestHeader = request.headers.get('x-middleware-subrequest')

if (subrequestHeader) {
  // Skip middleware entirely
  return NextResponse.next()
}

So if your middleware looked like this:

// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  const token = request.cookies.get('session-token')

  if (!token) {
    return NextResponse.redirect(new URL('/login', request.url))
  }

  return NextResponse.next()
}

export const config = {
  matcher: ['/dashboard/:path*', '/api/admin/:path*']
}

An attacker just needed to do this:

curl https://yourapp.com/api/admin/users \
  -H "x-middleware-subrequest: 1"

And they were in. No session token, no cookie, no auth. Your middleware never ran. The request went straight to your route handler.

The patch and which versions are affected

The Next.js team patched this in versions 15.2.3, 14.2.26, 13.5.9, and 12.3.5. If you are running anything older than those you need to update right now. Not next sprint, not after the current ticket ships, today.

The fix itself was straightforward. The framework now validates the subrequest header against an internal value that an external request cannot forge. It also strips the header from incoming requests so an attacker cannot inject it.

But here is the thing. Patching the framework is necessary but it is not the real lesson here. The real lesson is architectural.

Why middleware-only auth was always a bad idea

Even before this CVE I was telling people not to rely solely on middleware for auth. Middleware is request-level code that runs before your route handlers. It is fast and convenient and great for redirecting unauthenticated users to a login page. But it has a fundamental problem: it sits outside your application logic.

When you put auth only in middleware you are saying "I trust a layer above my application to enforce security." If that layer is bypassed, skipped, misconfigured, or just has a bug then your application has zero protection. There is no fallback. The route handlers assume the request is authenticated because middleware checked it.

This is what defense in depth is about. You want multiple layers so that if one fails the others catch it. Middleware is your first layer, not your only layer.

What you should actually do

Here is the pattern I recommend and the one I use in every Next.js project.

1. Patch your framework

Do this first. Right now.

npm install next@latest
# or if you are on a specific major
npm install next@15.2.3

2. Keep middleware for redirects and UX

Middleware is still useful. Use it to check if a user is logged in and redirect them to the login page. That is a UX concern, not a security concern.

// middleware.ts (or proxy.ts in Next.js 16+)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  const token = request.cookies.get('session-token')

  if (!token) {
    return NextResponse.redirect(new URL('/login', request.url))
  }

  return NextResponse.next()
}

export const config = {
  matcher: ['/dashboard/:path*', '/admin/:path*']
}

3. Do the real auth check inside your route handlers and server components

This is the layer that actually matters. Every route handler or server component that touches protected data should verify the session itself.

// app/api/admin/users/route.ts
import { verifySession } from '@/lib/auth'

export async function GET() {
  const session = await verifySession()

  if (!session) {
    return new Response('Unauthorized', { status: 401 })
  }

  // Now you can trust this request
  const users = await db.user.findMany()
  return Response.json(users)
}
// lib/auth.ts
import { cookies } from 'next/headers'

export async function verifySession() {
  const cookieStore = await cookies()
  const token = cookieStore.get('session-token')

  if (!token) {
    return null
  }

  const session = await db.session.findUnique({
    where: { token: token.value }
  })

  if (!session || session.expiresAt < new Date()) {
    return null
  }

  return session
}

4. For server actions, do the same thing

Server actions are public endpoints. Anyone can call them if they know the URL. Do not assume middleware will protect them.

'use server'

import { verifySession } from '@/lib/auth'

export async function deleteAccount() {
  const session = await verifySession()

  if (!session) {
    throw new Error('Unauthorized')
  }

  await db.user.delete({
    where: { id: session.userId }
  })
}

A helper to reduce boilerplate

You do not want to write the same check in every route handler. Create a small wrapper.

// lib/with-auth.ts
import { verifySession } from '@/lib/auth'

type AuthenticatedHandler = (
  request: Request,
  session: NonNullable<Awaited<ReturnType<typeof verifySession>>>
) => Promise<Response>

export function withAuth(handler: AuthenticatedHandler) {
  return async (request: Request): Promise<Response> => {
    const session = await verifySession()

    if (!session) {
      return new Response('Unauthorized', { status: 401 })
    }

    return handler(request, session)
  }
}

Now your route handlers stay clean:

// app/api/admin/users/route.ts
import { withAuth } from '@/lib/with-auth'
import { db } from '@/lib/db'

export const GET = withAuth(async (_request, session) => {
  const users = await db.user.findMany()
  return Response.json(users)
})

What about API keys and service-to-service auth

Same principle. If you have an API endpoint that accepts an API key, check the key inside the route handler. Middleware can be a first-pass filter but the actual key validation belongs in the handler.

// app/api/webhooks/stripe/route.ts
export async function POST(request: Request) {
  const signature = request.headers.get('stripe-signature')

  if (!signature) {
    return new Response('Missing signature', { status: 400 })
  }

  // Verify the Stripe webhook signature here
  // This is the actual security check, not middleware
  const event = stripe.webhooks.constructEvent(
    body,
    signature,
    process.env.STRIPE_WEBHOOK_SECRET
  )

  // Handle the event
}

The takeaway

Middleware is not a security boundary. It never was. CVE-2025-29927 just made that obvious to everyone at the same time. Patch your framework version, then go look at your route handlers and server actions and ask yourself: if middleware did not exist, would this endpoint still be protected? If the answer is no, you have work to do.

I fix Next.js production issues for a living. If your app was built on middleware-only auth and you need help auditing what was exposed, email me.

References