LEAKCADE

Next.js middleware authentication: the CVE-2025-29927 bypass and the fix

Updated 8 min read

Use Next.js middleware authentication only as a quick redirect, never as the only lock. CVE-2025-29927, a publicly tracked security flaw in Next.js, let attackers skip middleware completely on unpatched, self-hosted versions, and gaps in your middleware matcher (the setting that lists which paths middleware runs on) can skip it by accident. Upgrade Next.js, then verify the session again inside every route handler, server action and data function.

QUICK CHECKLIST

  • Upgrade Next.js to the latest patch of a supported major version
  • Verify the session in every route handler and server action
  • Put auth and ownership checks in a server-only data layer
  • Use :path* in matchers and decide on /api on purpose
  • Keep middleware to cheap redirects for logged-out visitors
  • Turn middleware off locally and confirm your data stays locked

Why Next.js middleware authentication can't be your only check

Middleware runs before a request reaches your pages and API routes. That makes it a tempting place for one big "no cookie, go to /login" check. It's one file, and once the demo works the app feels protected.

Vercel's postmortem on the bug is direct: "We do not recommend Middleware to be the sole method of protecting routes in your application." The pattern it describes is an optimistic check: look for an auth cookie and redirect early.

Optimistic means cheap and sometimes wrong. A cookie that exists isn't necessarily valid. Middleware can decide where to send a visitor. It can't prove who they are.

How the middleware bypass reaches your dataAn outside request carries the internal x-middleware-subrequest header. On an unpatched version Next.js skips middleware, the route handler has no check of its own, and private data is returned to a stranger.REQUESTCarriesx-middleware-subrequestGATEMiddlewareskipped, no authcheckROUTEHandler has nocheck of its ownDATAPrivate rows goto a strangerREQUESTCarries x-middleware-subrequestGATEMiddleware skipped, no auth checkROUTEHandler has no check of its ownDATAPrivate rows go to a stranger
Unpatched versions only. A check inside the route would stop this.

Put the check in the code that reads or writes the data. The Next.js data security guide recommends a data access layer for new projects: a server-only module that checks authorization and returns minimal data. If your app has an admin area, read exposed admin routes too.

How attackers get past middleware

CVE-2025-29927 is an authorization bypass in Next.js middleware. The advisory rates it critical (9.1 out of 10 on CVSS, a standard severity scale) with low attack complexity, no privileges required and no user interaction. For anything guarded only by middleware, that's an authentication bypass.

Next.js uses an internal header, x-middleware-subrequest, to tell its own routing that middleware already ran. On unpatched versions, a request from outside could carry that header too, and the app would skip middleware. An auth check that lived only there never ran.

Fixed versions:

Self-hosted apps using next start or output: 'standalone' were affected. Vercel-hosted apps were protected automatically. Vercel's postmortem says Netlify and Cloudflare Workers were never affected, and static exports have no middleware to bypass.

Your own middleware matcher can skip middleware too, with no CVE involved. The matcher decides which paths middleware runs on, and gaps are easy to create:

Request path/dashboard/:path*/dashboard/:path/((?!api|_next/static).*)
/dashboard/teamMatchedMatchedMatched
/dashboard/team/xMatchedNot matchedMatched
/api/invoicesNot matchedNot matchedNot matched

Which middleware matcher patterns run for which paths, based on the Next.js docs. Test your own patterns before you rely on them.

Middleware-only auth, before and after

The first version looks fine in a browser: log out, open /dashboard, and you land on the login page. The API route sits outside the matcher, though, and even for matched paths middleware only checks that a cookie called session exists, so a cookie with the value junk passes.

BEFOREBefore: middleware is the only lock
// middleware.ts: the only auth check in the app
import { NextResponse, type NextRequest } from "next/server";

export function middleware(req: NextRequest) {
  if (!req.cookies.get("session")) {
    return NextResponse.redirect(new URL("/login", req.url));
  }
  return NextResponse.next();
}

export const config = { matcher: ["/dashboard/:path*"] };

// app/api/invoices/route.ts: trusts middleware, checks nothing
import { db } from "@/lib/db";

export async function GET() {
  return Response.json(await db.invoice.findMany());
}
AFTERAfter: the data layer checks the user
// app/api/invoices/route.ts
import { getMyInvoices } from "@/data/invoices";

export async function GET() {
  const invoices = await getMyInvoices();
  if (!invoices) return new Response("Unauthorized", { status: 401 });
  return Response.json(invoices);
}

// data/invoices.ts
import "server-only";
import { db } from "@/lib/db";
import { getUser } from "./auth";

export async function getMyInvoices() {
  const user = await getUser();
  if (!user) return null;
  return db.invoice.findMany({
    where: { userId: user.id },
    select: { id: true, amount: true, status: true },
  });
}
Middleware can stay as a redirect. The lock moves to the code that reads the data.

How to fix middleware-only auth, step by step

To protect routes in Next.js, put the check next to the data. The steps work with a custom session or an auth library, and the Next.js authentication guide recommends a library over a hand-rolled session.

Using a provider? With Supabase, call getUser() on the server and avoid getSession() (see Supabase getSession vs getUser). With Clerk or NextAuth, see common Clerk and NextAuth mistakes.

  1. Upgrade Next.js

    Run npm ls next, then move to the latest patch release of a supported major version. 15.2.3, 14.2.25, 13.5.9 and 12.3.5 are only the minimums that fix CVE-2025-29927, and later releases may carry other security fixes.

    The advisory lists no patch for 11.x, so upgrade or block external requests that carry x-middleware-subrequest at your proxy or CDN. Upgrade even if you host on Vercel, which protected apps automatically.

  2. Write one getUser() in a server-only file

    This example verifies a signed session cookie with jose (Next.js 15 syntax). It refuses to start without a real secret. Swap in your auth library's session check if you use one.

    // data/auth.ts
    import "server-only";
    import { cache } from "react";
    import { cookies } from "next/headers";
    import { jwtVerify } from "jose";
    
    const secret = process.env.SESSION_SECRET;
    if (!secret || secret.length < 32) throw new Error("SESSION_SECRET is missing or too short");
    const key = new TextEncoder().encode(secret);
    
    export const getUser = cache(async () => {
      const token = (await cookies()).get("session")?.value;
      if (!token) return null;
      try {
        const { payload } = await jwtVerify(token, key, { algorithms: ["HS256"] });
        return typeof payload.userId === "string" ? { id: payload.userId } : null;
      } catch {
        return null;
      }
    });
  3. Call it everywhere data is read or changed

    Put getUser() at the top of every route handler, server action and data function. Return 401 or redirect when it gives back null. Then scope the query to that user, because a logged-in user isn't automatically allowed to touch every record (see IDOR in API routes).

    "use server";
    import { db } from "@/lib/db";
    import { getUser } from "@/data/auth";
    
    export async function deleteInvoice(id: string) {
      const user = await getUser();
      if (!user) throw new Error("Unauthorized");
      await db.invoice.deleteMany({ where: { id, userId: user.id } });
    }
  4. Fix the matcher

    Use :path* so nested paths match. Don't count on the matcher for API routes. Either add '/api/:path*' as an extra layer or leave it out, and check inside each handler, because that check is the one that counts.

    export const config = {
      matcher: ["/dashboard/:path*", "/account/:path*"],
    };
  5. Shrink middleware to an optimistic redirect

    Middleware reads the cookie and redirects visitors who have none. No database call, and no promise that the cookie is valid. Your pages call getUser() and redirect to /login too, so a fake cookie gets stopped one layer later.

Skipping step 3 is the common mistake: a page-level check doesn't cover the server actions or API routes behind it, and each of those is its own entry point.

Test it yourself

Run these against your own app only: localhost or a staging copy you own. Don't point them at anyone else's site.

npm ls next
curl -i http://localhost:3000/dashboard
curl -i http://localhost:3000/api/invoices
curl -i -H "Cookie: session=junk" http://localhost:3000/api/invoices
curl -i -H "Cookie: session=junk" http://localhost:3000/dashboard

What a good result looks like:

  1. npm ls next shows at least 15.2.3, 14.2.25, 13.5.9 or 12.3.5 (the CVE-2025-29927 fix). Ideally it shows the latest patch release of a supported major version.
  2. Logged-out /dashboard sends you to the login page.
  3. /api/invoices returns 401 with no cookie and with the junk cookie.
  4. /dashboard with the junk cookie redirects or renders no private data.

A bad result is a 200 with JSON on either API call, or private data in the page HTML. That means the route trusts middleware, or middleware only checks that the cookie exists.

To check that middleware isn't your only lock, rename your middleware or proxy file (middleware.ts/.js or proxy.ts/.js, in the project root or src/) to something like middleware.off.ts, restart the dev server and repeat the commands. Same results mean your data layer is doing its job. Different results mean middleware was the only lock. Rename the file back afterwards.

If you rely on a proxy rule instead of upgrading, ask whoever runs your proxy to confirm it removes x-middleware-subrequest from incoming requests.

Fix prompt you can paste

Paste this into Cursor or Claude Code from the repo root.

FIX PROMPT
Audit how this Next.js app protects routes and data.

1. Run npm ls next (or read package-lock.json, pnpm-lock.yaml or yarn.lock) and report the installed next version. Do not rely on the range in package.json. Flag it if it is below 15.2.3, 14.2.25, 13.5.9 or 12.3.5 for its major version (CVE-2025-29927).
2. Find middleware.ts, middleware.js, proxy.ts or proxy.js. List its matcher and every path check inside it (pathname ===, startsWith) with file and line number. Flag any check that only tests whether a cookie exists without verifying it. Say which of these the matcher does not cover: /api, nested paths, server actions.
3. List every route handler (app/**/route.ts), every file with 'use server', and every server component or function that reads or writes the database. For each one, say whether it verifies the session itself (a real check such as jwtVerify or your auth library's getUser, not just a cookie-exists test) and whether the query is filtered by the signed-in user's id. Flag every findMany(), find() or delete call with no user filter. Output file path and line number for every finding.
4. Before editing anything, print the findings from steps 1 to 3 as a list of file:line entries. Then fix the gaps: create a server-only getUser() in data/auth.ts that validates the session, call it in every unprotected handler, action and data function, and scope queries by user id. Return 401 or redirect when there is no user.
5. Reduce middleware to a cheap redirect for logged-out visitors and change matchers to use :path* for nested paths.
6. Explain every change in plain words and list anything you were unsure about. Do not change unrelated code.

Layered checks at a glance

Each layer handles one thing:

If middleware is skipped, the data layer still holds.

This is one item in a longer list, and the vibe coding security checklist covers the rest. If you want a second opinion on your own app, Leakcade can scan it.

Further reading

  1. GitHub advisory: Authorization Bypass in Next.js Middleware (CVE-2025-29927)
  2. Vercel: Postmortem on Next.js Middleware bypass
  3. Next.js docs: How to think about data security
  4. Next.js docs: How to implement authentication
  5. Next.js docs: proxy.js (formerly middleware) and matcher syntax

FAQ

What is CVE-2025-29927?

A critical authorization bypass in Next.js middleware, rated CVSS 9.1. On unpatched versions, a request carrying the internal x-middleware-subrequest header made Next.js skip middleware, so any auth check that lived only there never ran.

Is my Next.js app affected by the middleware bypass?

Yes, if you self-host with next start or standalone output, run an affected version (11.1.4 up to, but not including, 12.3.5, 13.5.9, 14.2.25 or 15.2.3 for your major version), and rely on middleware for auth. Vercel-hosted apps were protected automatically, and Netlify and Cloudflare Workers were never affected. Run npm ls next to see your version.

Can I still use middleware for authentication in Next.js?

Yes, as a fast redirect for logged-out visitors. Don't treat it as the only check. Verify the session again in route handlers, server actions and your data layer.

How do I protect routes in Next.js without relying on middleware?

Write one server-only getUser() that verifies the session, then call it in each page, route handler, server action and data function. Return 401 or redirect when it gives back null, and filter every query by the signed-in user's id so people can only reach their own records.

What if I can't upgrade Next.js?

The advisory's workaround is to block external requests that contain the x-middleware-subrequest header before they reach your app, using whatever sits in front of it (a reverse proxy, which forwards traffic to your app, or a CDN). For 11.x that's the only option listed, because there's no patch. Adding session checks inside route handlers and your data layer protects you either way.

Want a second pair of eyes?

We review your whole repo and write a fix prompt for every finding. $19, report within an hour.

Check my repo

Leakcade is an automated check. It can miss vulnerabilities and is not a substitute for a professional security audit or pentest. Provided as is. You stay responsible for your code. Where the law allows, our liability is limited to what you paid ($19). Terms

Patch Notes by Leakcade

A short weekly email: what changed in AI coding tools, one real security mistake, one prompt you can reuse.