# One header skipped my Next.js middleware, and my auth with it

A Next.js advisory with a CVSS of 9.1 came out on Friday, and by Saturday morning my timeline was full of the same curl command. I spent a good part of the weekend on it, partly patching and partly feeling a bit daft about where I'd put auth in a couple of apps.

## What the bug was

Middleware runs before a request reaches your page or route handler, which is why so many of us put "no session cookie, redirect to /login" there. One file, every route covered.

Internally, Next.js has to stop middleware from calling itself in a loop, so it tags its own internal requests with a header, `x-middleware-subrequest`, and skips middleware when it sees it. The trouble was that nothing checked where that header came from. Anyone could send it, the server would treat the request as one of its own, and middleware simply didn't run. If middleware was your only auth check, the check was gone (along with anything else you did there, like CSP headers or a maintenance-page rewrite).

It's [CVE-2025-29927](https://nvd.nist.gov/vuln/detail/CVE-2025-29927), advisory [GHSA-f82v-jwr5-mffw](https://github.com/vercel/next.js/security/advisories/GHSA-f82v-jwr5-mffw), disclosed on 21 March 2025. Self-hosted apps running `next start` or the standalone server were exposed, which is exactly how I run things in Docker at home. Vercel and Netlify weren't, and static exports have no middleware to skip. The fixes are 15.2.3, 14.2.25, 13.5.9 and 12.3.5.

## Testing my own apps

The value the header needs depends on the version. Older releases want the middleware's name (`middleware`, or `src/middleware` if you use a `src/` folder). Newer ones count recursion depth and give up after five, so the value is the name repeated five times with colons. I only tried this against apps I own, from my own machine:

```bash
curl -s -o /dev/null -w '%{http_code}\n' https://myapp.example.com/dashboard
# 307, off to /login as expected

curl -s -o /dev/null -w '%{http_code}\n' \
  -H 'x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware' \
  https://myapp.example.com/dashboard
# 200
```

That 200 was not a fun thing to see. An internal tool on 15.1 rendered its dashboard with no session at all. Its data didn't load, because the API behind it checked the token itself, but the page shell came back with nav items and labels that had no business being public. On an older 14.x side project the `src/middleware` form was the one that worked, so try both if you're checking.

Patching was dull, which is how you want it:

```bash
npm install next@15.2.3
npm ls next   # make sure nothing else is pulling in an older copy
docker build --no-cache -t myapp:latest .
```

I built without cache because I didn't want to find out on Monday that a layer was still holding the old `node_modules`. After the redeploy, the same curl gave a 307. My apps sit behind a reverse proxy, so I also strip the header there; there's no reason a request from outside should ever carry an internal framework header, patch or no patch:

```nginx
location / {
    proxy_set_header x-middleware-subrequest "";
    proxy_pass http://127.0.0.1:3000;
}
```

An empty value in nginx means the header isn't forwarded at all. On Cloudflare, a transform rule that removes the request header does the same thing at the edge.

## Where the auth should have been

The patch was the easy bit. The uncomfortable bit was the shape of the internal tool. Middleware read the cookie, verified the session and redirected, and the page simply assumed that if it was rendering, the user must be allowed:

```ts
// middleware.ts
export async function middleware(req: NextRequest) {
  const session = await getSession(req);
  if (!session) {
    return NextResponse.redirect(new URL("/login", req.url));
  }
}

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

```tsx
// app/dashboard/page.tsx
export default async function Dashboard() {
  const reports = await db.report.findMany(); // nobody asks who is asking
  return <ReportList reports={reports} />;
}
```

Plenty of tutorials teach exactly this, and it means one framework bug becomes an auth bypass. Now the page asks who the user is, and the query is scoped to them:

```tsx
// lib/auth.ts
import "server-only";

export async function requireUser() {
  const session = await getSession();
  if (!session) redirect("/login");
  return session.user;
}

// app/dashboard/page.tsx
export default async function Dashboard() {
  const user = await requireUser();
  const reports = await db.report.findMany({ where: { orgId: user.orgId } });
  return <ReportList reports={reports} />;
}
```

Middleware still does the redirect, since it's nicer for people to bounce to login before anything renders, but it's a convenience now. The lock sits next to the data.

The API that saved me is written in Go, and every handler checks the token because there's no magic file in Go that runs first. Slightly funny that the more boring stack had the safer habit.

If you're still on an old version, upgrade to the patched release for your line. If you really can't this week, strip `x-middleware-subrequest` at your proxy or CDN today, then go through anything that relies on middleware for auth and add a check in the page, route handler or server action itself.

---

*First published on [my blog](https://blogs.kishankanejiiya.tech/posts/next-middleware-bypass). Cover photo: ImagePerson, CC BY-SA 4.0, via Wikimedia Commons ([source](https://commons.wikimedia.org/wiki/File:Padlock_7642.jpg)).*

