WhatsAppDamascusSat – Thu·10:00 AM – 7:00 PM

Middleware is not authorization

Four NestJS advisories in seven months, all one bug: the matcher and the handler disagreed about the request. The check belongs where the data is read.

SecurityPublished 3 min read

Four patches, one bug

Between December 2025 and June 2026, @nestjs/platform-fastify shipped four separate security releases for what is, read closely, the same vulnerability:

  • CVE-2025-69211 — middleware skipped on a percent-encoded path. Fixed in 11.1.11.
  • CVE-2026-2293 — skipped again when Fastify's path-normalization options are enabled. Fixed in 11.1.14.
  • CVE-2026-33011 — skipped by sending HEAD instead of GET, because Fastify routes a HEAD request to the matching GET handler. Fixed in 11.1.16.
  • CVE-2026-54281 — skipped by appending a trailing slash to the URL. Fixed in 11.1.24.

Four fixes in seven months. Each one is correct and each one was worth shipping. But a bug that keeps returning under a new number is usually not a bug. It is a design position that does not hold.

The matcher and the handler disagree

Route-scoped middleware means two systems read the same request and each decides what it is. The middleware layer matches the path against a pattern to decide whether to run. The router matches the same path against a route to decide what to execute. Those are two separate implementations of the question "which route is this?", and every advisory above is a case where they answered it differently.

/admin and /admin/ are one route to the router and two strings to the matcher. A percent-encoded path is one thing before decoding and another after. HEAD /admin matches no GET middleware and reaches the GET handler anyway. The handler runs. The guard did not.

This class cannot be closed by enumerating cases. The list is as long as the set of strings two parsers can disagree about, and it grows every time either parser gains an option.

Next.js, same mistake

CVE-2025-29927, CVSS 9.1: a request carrying an x-middleware-subrequest header was treated as an internal call and skipped middleware entirely. Patched in 15.2.3, 14.2.25, 13.5.9 and 12.3.5. Not a parser disagreement this time — a trust boundary that accepted a client-supplied header as proof of internal origin — but the same outcome for the same structural reason: the authorization decision sat somewhere a request could get past.

Then it happened again. CVE-2026-64642, July 2026: App Router applications built with Turbopack and exactly one entry in config.i18n.locales could have middleware-based authentication bypassed. Fixed in 16.2.11. The advisory's own workaround is worth reading as written:

enforce authorization in the page's server-side data path
instead of relying solely on middleware

That is the framework telling you where the check belongs.

Are you exposed

  • Do you have a middleware.ts that redirects unauthenticated users, and pages or route handlers that assume it ran?
  • Is a Nest MiddlewareConsumer.forRoutes() call the only authorization check standing in front of a route?
  • If you deleted the middleware file outright, would any protected data become readable?

If the answer to the third question is yes, your middleware is your access control, and it is one parser disagreement away from being absent.

Where the check goes

At the point the data is read. The function that fetches the record is the last place in the request that cannot be routed around, because there is no second implementation of it competing to decide what it matched. There is one function that reads that row, and either the check inside it runs or the row is not read.

In practice: the session lookup and the ownership test live in the data layer, or in one function every handler calls before it returns anything. That is more code than a single middleware file, and it is repeated in a way that looks redundant right up until the day it is not. It is also the only version that survives the framework changing its mind about what a trailing slash means.

What middleware is still for

Everything that is not a security decision. Locale negotiation, redirects, adding response headers, rejecting obviously malformed traffic early so the expensive path never runs. It is a good place to make a cheap decision and a bad place to make the only one.

Treat it as an optimisation. If it is skipped, the request should get slower — not more privileged.