The route your matcher never matched
The concrete launch risk is narrow and easy to miss: your Next.js middleware redirects an unauthenticated visitor away from /dashboard, you confirm it, and you assume every private page is covered. But middleware only runs on the paths its matcher config selects. A route that the matcher never matches gets no redirect at all. So /dashboard sends an anonymous visitor to the login page, while /dashboard/billing, /admin, or /account/settings render straight through to anyone who types the URL.
This is the Next.js middleware authentication security gap in one sentence: the protection is configured per path pattern, and the pattern is usually narrower than the set of pages it was meant to cover. The pages you clicked through during testing are guarded. The ones you reached only by following links inside an already-logged-in session may never have been visited as a cold, logged-out URL, which is exactly how a stranger arrives.
Separate what is observable from what is only possible. What an outside visitor can observe is whether a given URL redirects to login or returns page content without a session. What they cannot see from the outside is your matcher config, your middleware code, or whether the page also does a server-side check. A page that renders for a logged-out caller is an observable signal worth investigating. It is not, by itself, proof that private data leaked, because the page might still gate its data fetch separately.
Why AI-built Next.js apps land here
When you ask an AI tool to add auth, it commonly produces a middleware.ts that reads a session cookie and redirects when it is absent, plus a matcher that lists the routes mentioned in the prompt. That code works on the routes you named. The generated matcher is a snapshot of the conversation, not a map of your route tree, so every page you add later sits outside it unless you remember to widen the pattern.
A few recurring shapes make this worse:
The deeper reason is the skipped review loop. The generated flow makes the happy path work on the first deploy, which makes it look finished, so the per-route question of who should reach each path never gets asked before the URL goes public.
- The matcher lists specific prefixes like /dashboard and /admin, then a new feature ships under /app or /account and is never added to the list.
- Dynamic segments such as /projects/[id] are present but nested routes like /projects/[id]/export are assumed to inherit protection. They do not; matching is by path pattern.
- The middleware checks only that a session cookie exists, not what it contains. That is authentication (is anyone logged in) standing in for authorization (is this person allowed here). A logged-in non-admin still reaches /admin.
- Route handlers under /api are treated as covered by page middleware, when the data route needs its own guard.
Check every private route from a clean session
You can verify this on your own app with no attack and no tooling beyond a browser. The goal is to reach each private route as a stranger would.
Open a fresh private or incognito window so you carry no session cookie. Then visit each route directly by typing or pasting the full URL, rather than clicking through the app, because clicking starts from a logged-in state and hides the gap.
Build the list of routes to test from your own app directory, not from memory. Walk the app or pages folder and write down every page route, including nested folders and dynamic segments, then test each one logged out. Pay special attention to routes added after the auth code was written, sibling routes one level deeper than a page you know is protected, and any /api route handler that returns data. For API routes, a plain GET from a logged-out session that returns JSON rather than a 401 or 403 is the same gap on the data layer.
- Suspicious result: the page renders its real content, or shows a brief authenticated layout before any redirect. Content reaching a logged-out caller means middleware did not gate that path.
- Safer result: the request redirects to your login or sign-in route before any private content paints, and the address bar lands on the auth page.
- Watch for client-only gating. If the protected content flashes on screen and a redirect happens a moment later in the browser, the guard is running in client JavaScript after render, not at the edge. A visitor who disables script, or reads the initial HTML response, still sees it.
Close the gap at the server, not only the edge
The fix has two parts: widen the matcher so middleware actually runs on every private path, and back it with a server-side check so the edge is not your only line.
Make the middleware matcher inclusive rather than enumerated. Instead of listing individual prefixes that drift out of date, prefer a pattern that runs middleware on everything except clearly public assets and the public marketing pages, so a newly added private route is covered by default rather than forgotten. Then, inside the middleware, redirect when there is no valid session.
The part the edge cannot do alone is verify identity and permission at the point data is read. Add a server-side auth check in the protected page's own server component, route handler, or data-loading function: confirm there is a session, and confirm that session is allowed to see this specific resource before returning anything. This is where authentication becomes authorization, and it belongs next to the data, not only in a path filter that runs earlier.
State the limits plainly. A correct matcher and a session redirect stop the casual unauthenticated visitor, but they do not by themselves enforce row-level or object-level ownership. Middleware that confirms a session exists will happily pass a logged-in user toward another user's record at /orders/12345 unless the data layer checks ownership too. That object-level authorization, often the IDOR class in OWASP and CWE taxonomy, is a separate control. Middleware gating and per-request authorization solve different problems, and you need both.
Where VibeCodeGuard fits, and what it cannot prove
VibeCodeGuard's Launch Check probes route paths from the public surface and reports which pages load without an auth redirect. That is exactly the observable side of the matcher gap: a private-looking URL that returns content to a clean, logged-out request is surfaced with severity and fix direction, so a route your middleware never matched shows up in your scan rather than in a stranger's address bar. It checks what an outside visitor can actually reach, with no access to your code.
What it does not do is read your middleware.ts, your matcher config, or your session logic. It cannot confirm that a route which does redirect is also enforcing the right permission for the right user, and it cannot test object-level authorization, because that requires knowing who should own which record.
Next step: open a private window, build the route list from your app directory, and visit every private path logged out, including the ones added after you wrote the auth code. Then run a focused Launch Check against the public URL before you share it, so an unguarded route is something you find first.
A clean Launch Check means the routes it probed redirected as expected from the outside. It does not prove your authorization is correct: it cannot see whether a logged-in user can reach another user's data, whether every nested route was in scope, or whether a server-side check backs the edge. Treat a flagged open route as a launch blocker, and treat a clean result as one signal, not a guarantee that auth is done.
> launch check
Scan the public surface before launch.
Get severity, evidence, and practical fix guidance for the checks VibeCodeGuard can run from the outside.