Rebounder Tech Blog

Written by the people who actually run these systems in production.

middleware matcher negative lookahead is a prefix match

Published About 4 min readBy the Rebounder engineering team — the people who operate these systems

This article may contain affiliate links. Its content is not affected by advertising.

In short

A bare token in a Next.js config.matcher negative lookahead matches by prefix, so a route merely starting with that token also gets silently excluded from the auth middleware.

Conclusion

The negative lookahead (?!token) you write inside Next.js’s config.matcher is a plain JavaScript prefix match: a bare token only asks “does the string start with this?” Kimiteras v2’s auth middleware (apps/web/middleware.ts) had written login, student, api/auth, and api/health without any boundary, so any later route that merely shared that prefix — /api/authority, /students, and similar protected routes that didn’t exist yet — matched the same negative lookahead and was silently dropped from the middleware’s auth gate. The fix was to append a (?:/|$) boundary right after each token.

The failure mode

Found on 2026-05-31 in PR #250 (#139 L3). Before the fix, config.matcher read:

// middleware.ts (before the fix, sha 26c1858a)
export const config = {
  matcher: [
    "/((?!login|s/|student|signage/|guide(?:/|$)|api/auth|api/health|api/guide/|_next/static|_next/image|favicon.ico|.*\\.(?:png|jpg|jpeg|gif|svg|ico|webp|css|js|map|woff|woff2|ttf)$).*)",
  ],
};

login, student, api/auth, and api/health were still bare prefixes (guide alone had already been boundaried to guide(?:/|$) after an earlier, unrelated fix). With this pattern in place, adding a protected route like /api/authority later meant the negative lookahead matched it too — it still starts with api/auth — so it fell into config.matcher’s exclusion list, which means no middleware, which means no auth gate. The surrounding docstring already warned that future protected routes such as /students, /api/authority, /api/healthcheck, or /loginland could be silently dropped.

Cause

JavaScript’s negative lookahead (?!...) only tests whether the text starting at that position begins with one of the given alternatives. With no word-boundary or end-of-string anchor, whatever follows the token is irrelevant — the lookahead already matched. guide had received a guide(?:/|$) boundary in an earlier PR, but the other four tokens were left untouched. Code review flagged the same shape of problem — a prefix match standing in for an exact match — and all four were boundaried together in this change.

The fix

A (?:/|$) boundary — slash or end of string — was appended right after each token:

// middleware.ts (after the fix, sha 787653311)
export const config = {
  matcher: [
    "/((?!login(?:/|$)|s/|student(?:/|$)|signage/|guide(?:/|$)|api/auth(?:/|$)|api/health(?:/|$)|api/guide/|_next/static|_next/image|favicon.ico|.*\\.(?:png|jpg|jpeg|gif|svg|ico|webp|css|js|map|woff|woff2|ttf)$).*)",
  ],
};

s/, signage/, and api/guide/ already ended in /, so they were already boundaried and were left unchanged. The regression tests cover both directions: a token followed by unrelated characters now stays gated (protected), and the legitimate excluded paths — both bare and with a sub-path — stay excluded exactly as before.

// middleware.test.ts (added, sha 787653311)
it("boundary hardening (#139 L3): tokens that were prefix matches no longer over-exclude look-alike protected routes", () => {
  expect(gated.test("/loginland")).toBe(true);
  expect(gated.test("/students")).toBe(true);
  expect(gated.test("/student-records")).toBe(true);
  expect(gated.test("/api/authority")).toBe(true);
  expect(gated.test("/api/auth-admin")).toBe(true);
  expect(gated.test("/api/healthcheck")).toBe(true);
  expect(gated.test("/api/health-internal")).toBe(true);
});

it("existing auth-free paths stay excluded (bare and sub-path both)", () => {
  expect(gated.test("/login")).toBe(false);
  expect(gated.test("/student")).toBe(false);
  expect(gated.test("/api/auth/session")).toBe(false);
  expect(gated.test("/api/auth/signout")).toBe(false);
  expect(gated.test("/api/health")).toBe(false);
});

Preventing recurrence

This middleware only checks that a session cookie exists; it’s a lightweight gate. The actual cookie verification and RLS context setup happen on the Server Component / Route Handler side, through getCurrentUser() / withSession(), as the final line of defense. So a matcher gap alone doesn’t produce an immediate authorization bypass — but it still strips away one layer of defense-in-depth. The docstring now states the intent and the boundaried tokens explicitly, and the test suite carries cases in both directions — bare and sub-path routes stay excluded, look-alike protected routes don’t — so if anyone adds a new prefix token without a boundary later, one of the two tests fails immediately.

Frequently asked questions

Q1Why does config.matcher's negative lookahead match by prefix?

JavaScript's negative lookahead (?!token) only checks whether the string at that position starts with token. With no boundary marker right after it, anything that continues past token still counts as a match, so config.matcher excludes it from the middleware.

Q2Which routes were being excluded by mistake?

login, student, api/auth, and api/health were left as bare prefixes, so adding a protected route such as /loginland, /students, /api/authority, /api/auth-admin, or /api/healthcheck later would slip straight past the middleware's auth gate.

Q3Why was guide already fixed before this change?

An earlier PR (#227, Reviewer Low-1) had already flagged the same bug for /guidelines getting swept up by a bare guide prefix, and fixed it with a guide(?:/|$) boundary. This change extends that same pattern to the remaining four tokens.

Q4What was the actual fix?

Adding a (?:/|$) boundary — a slash or the end of the string — right after each token. login(?:/|$) still matches /login and /login/xxx, but no longer matches /loginland, since what follows the token there is neither a slash nor the end.

Q5Is it safe for a route to fall outside the matcher's coverage?

This middleware only checks whether a session cookie exists; real cookie verification is the last line of defense in the Server Component or Route Handler. A matcher gap removes one layer of defense-in-depth, but it does not bypass authorization by itself.

Environment verified

  • Next.js ^16.0.0 (apps/web) / middleware.ts config.matcher
  • Fix commit 787653311 on 2026-05-31 (PR #250, #139 L3)

What this article is based on

  • TypeScript file lines 58-62commit 26c1858
  • TypeScript file lines 66-70commit 7876533
  • TypeScript file lines 82-92commit 7876533

Every claim in this article comes from the records above. The repositories we operate are private so we cannot link to them, but which file, which lines, and at which commit we read them is recorded for every article. Nothing here is written from guesswork.