DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Next 16 renamed middleware to proxy, and our whole auth boundary is one allowlist

I have been building Nakodo, a field guide to YouTube creators: it reads a brand's website, searches YouTube for creators who fit, collects the contact details those creators publish, and drafts a first message. It runs on Next 16.3.7 with Supabase for auth.

The first thing that surprised me on this build was that middleware.ts no longer exists. The file is src/proxy.ts and the named export is proxy. From the Next 16 docs shipped in the package:

Starting with Next.js 16, Middleware is now called Proxy to better reflect its purpose. The functionality remains the same.

There is a codemod (npx @next/codemod@latest middleware-to-proxy .) which renames the file, renames the export, and renames five config properties including skipMiddlewareUrlNormalize to skipProxyUrlNormalize. If you are following a tutorial written before this, the file name is the first thing that will bite you, and the symptom is the worst kind: nothing errors, your middleware simply never runs.

The rename is not only cosmetic. The same docs are explicit that this layer is not where authorisation lives:

Proxy is not intended for slow data fetching. While Proxy can be helpful for optimistic checks such as permission-based redirects, it should not be used as a full session management or authorization solution.

Our file does exactly three things, and I want to write down why each one is there, because each was a bug first.

1. It refreshes the session cookie, and the redirect has to carry it

The Supabase SSR client hands you a setAll callback when it wants to write refreshed auth cookies. The shape that works is slightly awkward: you write the cookies onto the request, rebuild the response from that request, then write them onto the response too.

const supabase = createServerClient(url, anonKey, {
  cookies: {
    getAll: () => request.cookies.getAll(),
    setAll: (toSet) => {
      for (const { name, value } of toSet) request.cookies.set(name, value);
      response = NextResponse.next({ request });
      for (const { name, value, options } of toSet) response.cookies.set(name, value, options);
    },
  },
});
const { data } = await supabase.auth.getClaims();
Enter fullscreen mode Exit fullscreen mode

The trap is one line further on. If the proxy then decides to redirect, NextResponse.redirect(url) is a brand new response, and every cookie Supabase just set on the old one is gone. The user gets bounced, their refreshed token is thrown away, and they get bounced again. So a redirect that happens after a refresh has to copy the cookies across:

function redirectWithCookies(url: URL, from: NextResponse) {
  const redirect = NextResponse.redirect(url);
  for (const cookie of from.cookies.getAll()) redirect.cookies.set(cookie);
  return redirect;
}
Enter fullscreen mode Exit fullscreen mode

2. It sends signed-out visitors to the login page, and remembers where they wanted to go

if (!data?.claims && !isPublicPath(path)) {
  const url = request.nextUrl.clone();
  url.pathname = "/login";
  url.search = `?next=${encodeURIComponent(path + request.nextUrl.search)}`;
  return NextResponse.redirect(url);
}
Enter fullscreen mode Exit fullscreen mode

You can watch this happen on the live site without an account. Ask for a page inside the app:

$ curl -sI https://nakodo.app/campaigns | grep -i -E "^HTTP|^location"
HTTP/2 307
location: https://nakodo.app/login?next=%2Fcampaigns
Enter fullscreen mode Exit fullscreen mode

Then ask for a public one and note there is no redirect at all:

$ curl -sI https://nakodo.app/pricing | grep -i "^HTTP"
HTTP/2 200
Enter fullscreen mode Exit fullscreen mode

Try it with a deeper path, say /settings/plan, and the next parameter comes back as %2Fsettings%2Fplan. That round trip is the entire reason the redirect is built here rather than in each page: there is exactly one place that knows how to encode it.

3. It redirects signed-in visitors away from the marketing home page

if (data?.claims && path === "/") {
  const url = request.nextUrl.clone();
  url.pathname = "/campaigns";
  url.search = "";
  return redirectWithCookies(url, response);
}
Enter fullscreen mode Exit fullscreen mode

This one is a rendering decision disguised as a routing decision. Plenty of apps put "if signed in, go to the app" inside the home page component. The moment you do that, the home page reads the session, and a page that reads the session cannot be static. Our landing page is the most cached thing we own and I want it to stay that way, so the one session-dependent decision about it lives in the proxy and the page itself never asks who you are.

The same rule applies to the pricing page and the privacy page. None of them read a session. The "Sign in" link in the header does not change based on who you are on the server.

The matcher excludes the two routes that authenticate themselves

export const config = {
  matcher: ["/((?!_next/static|_next/image|api/cron|api/stripe|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)"],
};
Enter fullscreen mode Exit fullscreen mode

Static assets are obvious. The two interesting exclusions are the scheduled jobs and the Stripe webhook, because both already prove who they are and neither has a session to refresh. The cron endpoints check a bearer token:

export function isCronRequest(request: Request): boolean {
  const secret = process.env.CRON_SECRET;
  return !!secret && request.headers.get("authorization") === `Bearer ${secret}`;
}
Enter fullscreen mode Exit fullscreen mode

Note the !!secret. With no secret configured, every request is refused rather than every request being allowed, which is the correct direction for that mistake to fall. The Stripe webhook verifies Stripe's signature instead. Running a Supabase token refresh in front of either one would be pure latency, and worse, a redirect in front of a webhook turns a payment event into a 307 that Stripe counts as a failure.

The allowlist is data, and a test guards it

The list of public paths is not inline in the proxy any more. It is a module of its own, because something else needs to agree with it:

const PUBLIC_EXACT = new Set(["/", "/pricing", "/privacy", "/terms", "/about", ...]);
const PUBLIC_PREFIXES = ["/guides/", "/youtubers/", "/for/", "/tools/", "/og/", "/login", ...];

export function isPublicPath(path: string): boolean {
  return PUBLIC_EXACT.has(path) || PUBLIC_PREFIXES.some((p) => path.startsWith(p));
}
Enter fullscreen mode Exit fullscreen mode

Here is the failure mode that made me pull it out, and it is nasty precisely because it is invisible to the person who built the page. You add a new public page. You open it. It works. It works because you are signed in. For everybody who is not, including every crawler, that URL is a 307 to /login, so the page never gets indexed and you find out weeks later from a traffic graph.

A deny-by-default auth boundary and a sitemap are two lists that have to stay in sync, and nothing in the type system connects them. So a test connects them:

test("every page in the sitemap is open to signed-out visitors", () => {
  for (const path of [...STATIC_PAGES.map((p) => p.path), ...ENTRIES.map((e) => e.path)]) {
    assert.ok(isPublicPath(path), `${path} would redirect crawlers to /login`);
  }
  for (const path of ["/opengraph-image", ...CARD_PATHS.map((p) => `/og${p}`)]) {
    assert.ok(isPublicPath(path), `${path}: social cards must be public for link previews`);
  }
});

test("the app stays private", () => {
  for (const path of ["/campaigns", "/campaigns/abc", "/settings/plan", "/runs", "/onboarding", "/guidesx"]) {
    assert.ok(!isPublicPath(path), `${path} should need a session`);
  }
});
Enter fullscreen mode Exit fullscreen mode

Both directions matter. The first test fails when a page you meant to be public is missing from the allowlist. The second one fails when a prefix is too greedy, which is what "/guidesx" is doing in that list: /guides/ with the trailing slash keeps /guidesx private, and if somebody ever "tidies" that slash away, a typo'd URL stops needing a session.

The assertion messages are written as the consequence rather than the condition. "/glossary would redirect crawlers to /login" tells you what is broken. "expected true, got false" tells you nothing at 11pm.

What I would take away from this

The proxy is a good place for a cheap, optimistic, deny-by-default check, and a bad place for anything that needs to be correct on its own. Every page in Nakodo still fetches the user and still filters its own queries by user.id. If the proxy were deleted tomorrow, nothing would leak; URLs would just be rude to signed-out visitors.

Two smaller rules I will keep: a redirect issued after a cookie refresh has to carry the cookies, and any list that quietly decides whether crawlers can see your pages should have a test pointing at it.

If you want to poke at the boundary, nakodo.app is open, pricing and privacy are public, and everything under /campaigns and /settings will bounce you to the login page with a next parameter. The free plan needs no card if you want to see what is on the other side of the redirect.

Top comments (0)