Middleware Analyzer
Next.js middleware only runs on the paths its matcher names. This analyzer lays that list over the API routes found in the repository and reports each route left outside, then checks whether the middleware really verifies a token and whether its matcher can be sidestepped.
Matcher vs route tree
The scanner holds two lists and finds the difference. On the left is what the middleware says it covers. On the right is what the repository actually exposes.
1A Next.js middleware.ts runs only on the paths its matcher lists. The scanner holds two things: that list, and the API routes found under app/api and pages/api. The question is which routes the list leaves out.
2Each matcher string is converted to a regular expression. :path* becomes an optional slash followed by anything, so /api/tasks/:path* reads as ^/api/tasks(/.*)?$.
3/api/tasks and /api/tasks/:id both match the second pattern. They are covered, and the middleware's auth check runs for them.
4/api/admin/users, /api/auth/login and /api/health match none of the patterns. Each uncovered route is its own Medium finding, 0.85. The scanner has no public-path list, so the intentionally public login and health routes are reported as well.
5Add /api/:path* and every route is covered. The public ones are then excluded inside the middleware function with an explicit allow-list, which is the shape the markdown recommends.
Matcher shapes
The same file can name its matcher in three styles. Each one changes what is covered and which extra findings appear.
1A matcher can be written three ways, and each is read differently. The same conversion applies to the config of any Next.js app.
2The common :path* form becomes a prefix pattern. It covers the path itself and everything below it, and its trailing * exempts it from the bypass checks.
3A literal path matches that path only. The scanner adds two Low findings, because a matcher with no * and no trailing slash may miss /admin/, and a literal lowercase path may miss a differently cased request.
4A regular-expression matcher is run on the route without its leading slash. The popular (?!api|_next/static) form excludes /api on purpose, so every API route lands outside the middleware. That is the pattern the markdown warns about.
What it reports
Four stages run in order on the middleware file. Each has its own severity, so the report separates a missing file from a leaky matcher.
1No middleware file in the repository is the broadest gap. High, 0.90, but only when there are API routes to protect and the repo is not an Express app.
2The coverage comparison from the first figure. Medium, 0.85 per route, so a matcher that misses ten routes produces ten findings.
3The middleware body is read for a token check. A check that only asks whether a cookie exists is Medium, 0.75: an attacker can send any cookie value. Any strong pattern in the file clears it.
4Last, the shape of each literal matcher. Low, 0.70, as a prompt to confirm that slashes and case are handled. These are the weakest findings in the group.
Why it matters
Middleware is often the first line of defence, and a matcher decides where that line stands. If it protects page routes but not API routes, an attacker skips the pages and calls the API directly: the browser reaches /dashboard through the check, curl reaches /api/tasks without it.
Optus (2022). A breach that exposed 9.8 million records was caused by an API endpoint with no authentication. The markdown notes that the middleware, or its equivalent, protected user-facing pages but not the API, which is the pattern this scanner looks for.
Vulnerable vs fixed code
// BAD: pages are covered, API routes are not
export const config = {
matcher: ["/dashboard/:path*"]
}
export function middleware(request: NextRequest) {
// weak: only asks whether a cookie exists
if (request.cookies.has("session")) return NextResponse.next()
return NextResponse.redirect(new URL("/login", request.url))
}// GOOD: protect both pages and API routes
export const config = {
matcher: ["/dashboard/:path*", "/api/:path*"]
}
export async function middleware(request: NextRequest) {
// exclude public API routes explicitly
const publicPaths = ["/api/auth/login", "/api/auth/register", "/api/health"]
if (publicPaths.some(p => request.nextUrl.pathname.startsWith(p))) {
return NextResponse.next()
}
// strong: the token is verified, not just present
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}The markdown example reads the Authorization header and rejects a request that has none. That proves a header exists, not that its token is valid. The analyzer does not recognise that form as a strong check, because its header pattern is lowercase and case-sensitive. Use a verification call such as getUser, getToken or verifyToken.
Limits and scope
| Behaviour | Detail |
|---|---|
| Input | middleware.ts or .js, at the repo root or in src/, plus the route map. No network. |
| Routes compared | API routes only (app/api/**/route.ts and pages/api/**). A page such as /dashboard is never in the list, so a matcher that misses a page is not reported. |
| Severity and confidence | No middleware: High 0.90. Uncovered route: Medium 0.85 each. Weak token check: Medium 0.75. Matcher bypass: Low 0.70 each. |
| Wildcards understood | Only the /:name* form. A :path+ or :path? segment is compared as literal text. |
| Public routes | No allow-list. Login, register and health routes outside the matcher are reported like any other. |
| Strong token check | Any of getUser(, getSession(, auth(), verifyToken(, cookies().get, getToken(, createServerClient and a few others. Note that cookies().get reads a cookie and counts as strong. |