Your Next.js middleware runs on every request — add a matcher
Next.js middleware is the one piece of your app that intercepts everything. A single middleware.ts at the project root sits in front of every request the app receives — and unless you tell it otherwise, "every" is literal. Page navigations, API calls, the favicon, _next/static chunks, prefetched routes, image-optimizer requests: all of it runs your middleware. If you wrote that file to gate auth and never exported a config.matcher, you've signed your whole app up to run an auth check on a CSS file.
That's not just wasteful. It's a correctness problem that bites in two directions: latency you didn't budget for, and request-handling logic firing on requests it was never designed for.
What "no matcher" actually means
When you export a middleware function and nothing else, Next.js runs it on all paths. There is no implicit exclusion list for static files — you opt into one. The pattern you'll see in the Next.js docs to skip framework internals looks like this:
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
But that block does nothing until you write it. Omit config entirely and your middleware function executes for /dashboard, sure — but also for /_next/static/chunks/main-abc123.js, /favicon.ico, and every prefetch the router fires when a user hovers a link.
Here's the canonical version of the mistake — an auth gate with no matcher:
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
// No config.matcher — this runs on EVERY request, including
// _next assets, images, and the favicon.
export function middleware(request: NextRequest) {
const token = request.cookies.get("session")?.value;
if (!token) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
This compiles, deploys, and "works" in a quick manual test — you're logged in, the app loads. The damage is invisible until you look at what's executing. Here's what GuardLayer reports on exactly that file — live engine output, not a mockup:
- Infomiddleware.ts:1
Middleware without a route matcher
Export an explicit config = { matcher: [...] } that lists exactly the protected paths, and confirm it covers every route that requires auth.
Why this is a real bug, not a style nit
1. Every static asset pays the tax. Middleware runs before the static-file handler and the route cache. On the Edge runtime that's added latency — and cold-start exposure — on every asset request; on a self-hosted Node deploy it's CPU spent checking a cookie before serving a PNG. A page with 30 chunks and 10 images is 40+ pointless middleware invocations per load.
2. Redirect logic leaks onto things that aren't pages. The snippet above redirects any request without a session cookie to /login. Apply that to /_next/static/chunks/main.js and an unauthenticated visitor's browser asks for a JS chunk and gets a 307 to an HTML login page instead. Best case: console noise and broken prefetches. Worse: a redirect loop, or login HTML served where the browser expected JavaScript and the Content-Type no longer matches.
3. It makes the protected surface unreadable. The point of a matcher is to state, explicitly and in one place, which routes the middleware runs on. When the answer is "everything, implicitly," nobody can open the file and know what's gated. Add an API route that should be public and you now have to reason about whether your catch-all check accidentally blocks it — instead of just reading a list. That's CWE-1188, an insecure default: a default that's easy to get subtly wrong.
The latency is the part people notice first. The fuzzy surface is the part that causes the incident six months later.
The fix: declare exactly where it runs
Export a config.matcher that lists the paths the middleware should run on — and nothing else.
Option A — match only what's protected (preferred for auth):
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const token = request.cookies.get("session")?.value;
if (!token) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*", "/settings/:path*", "/api/private/:path*"],
};
Now the middleware only fires for the route groups that actually need a session. /favicon.ico and your static chunks never touch it, and anyone reading the file sees the protected set in three lines.
Option B — run everywhere except assets (when you genuinely need broad coverage):
export const config = {
matcher: [
// Run on all paths except Next internals and static files.
"/((?!_next/static|_next/image|favicon.ico|.*\\..*).*)",
],
};
The negative-lookahead pattern excludes _next/static, the image optimizer, the favicon, and anything with a file extension (.png, .css, .js). Reach for this only when your middleware really does need to see most page routes — i18n, A/B tests, global headers. For pure auth gating, Option A is clearer and cheaper, and it won't accidentally skip a legitimate page route that happens to contain a dot.
A few rules of thumb:
- Explicit beats implicit. A literal list of protected prefixes is easier to audit than a clever exclusion regex you'll misread later.
- Matchers must be statically analyzable. Next.js reads them at build time — no runtime variables, no string concatenation. Use the array-literal form with plain strings.
- The matcher is a filter, not the authorization. It decides where middleware runs, not whether a caller is allowed. The check inside still has to be correct, and per-resource authorization still belongs where the resource is touched. A matcher narrows the surface; it doesn't replace the logic.
30-second self-check
# 1. Do you even have a middleware file?
ls middleware.ts middleware.js src/middleware.ts 2>/dev/null
# 2. Does it export a matcher?
grep -n "matcher" middleware.ts src/middleware.ts 2>/dev/null
# 3. If step 1 found a file and step 2 found nothing — you run on everything.
If grep comes back empty, your middleware is running on every request. Add a matcher before you ship the next feature on top of it. GuardLayer flags exactly this — a middleware file that does auth/routing work with no config.matcher — on every push, alongside its other Next.js, Supabase, and general checks, with the fix inline.
FAQ
Does no matcher actually run middleware on static files?
Yes. Without a config.matcher, Next.js runs middleware on all paths, including _next/static, the image optimizer, and favicon.ico. There is no implicit exclusion — those paths are skipped only when your matcher (or the documented exclusion pattern) leaves them out.
Is this a security vulnerability or a performance issue? Primarily a performance and correctness issue — you run logic on requests that never need it, which is why it's flagged as advisory rather than critical. It edges into security territory when the logic is path-dependent (redirects, header rewriting) and starts firing on requests it wasn't written for, or when an unexamined catch-all hides which routes are really protected.
What's the matcher pattern to exclude all static files?
"/((?!_next/static|_next/image|favicon.ico|.*\\..*).*)" runs on every path except Next internals and any request containing a file extension. For auth, prefer listing the protected prefixes explicitly instead.
Can I compute the matcher at runtime? No. Matchers are read at build time and must be static array literals — no variables, env reads, or concatenation. Keep them as plain strings.
My middleware only sets a header — do I still need a matcher? Yes. Even a "harmless" header pass runs on every asset request without one. Scope it to the routes that actually need the header.
Catch this before it ships — free
GuardLayer scans every push for this and 19 other Next.js + Supabase issues, with the exact fix inline.