Skip to main content
ASoc
Tutorial

Next.js Auth Middleware: Refresh in the Proxy, Authorize at the Data

The proxy keeps the session fresh; layouts and route handlers decide access. A 42-line proxy.ts and its matcher, run against 21 paths: 11 hit it, 10 skip.

The ASoc Team8 min read

Next.js auth middleware should do one job: keep the session fresh and let the request through. Redirecting anonymous visitors there is a convenience, not a security boundary, because a path your matcher misses is a path with no guard. Put the real check in the layout, route handler or query that touches the data.

That is the split this storefront ships. src/proxy.ts (Next 16's name for middleware) is 42 lines and never redirects anyone. The guard lives in src/app/dashboard/layout.tsx, the download gate lives in src/app/api/download/route.ts, and the entitlement check sits under both. Below is each layer as it exists in the repo, plus the matcher output I ran against 21 real and representative paths.

What each layer is for

LayerFile in this repoJobRedirects?Trust level
Proxy (middleware)src/proxy.tsRefresh the Supabase session, rewrite cookiesNoPlumbing only
Layout guardsrc/app/dashboard/layout.tsxGate every /dashboard/* pageYes, to /login?next=getClaims() (JWT validated)
Route handlersrc/app/api/download/route.tsGate the signed-URL issuerNo: 401, 403, 429 or a 302 to a signed URLgetUser() + entitlement lookup
Data layerentitlement_slots + RLSRow-level ownershipn/aDatabase

The reason for the split is blast radius. The proxy runs on nearly every request, so anything slow or throwing in it is site-wide. A layout guard only costs the routes it wraps, and a route handler can fail closed for one endpoint without touching the marketing pages. If you only remember one thing from the Next 16 rename, the proxy migration post covers the file mechanics; this post is about what to put in the file once it exists.

The proxy: refresh, never decide

This is the whole file, copied from the repo:

import { NextResponse, type NextRequest } from "next/server";
import { createServerClient } from "@supabase/ssr";

export async function proxy(request: NextRequest) {
  let response = NextResponse.next({ request });

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll: () => request.cookies.getAll(),
        setAll: (cookiesToSet) => {
          cookiesToSet.forEach(({ name, value }) =>
            request.cookies.set(name, value),
          );
          response = NextResponse.next({ request });
          cookiesToSet.forEach(({ name, value, options }) =>
            response.cookies.set(name, value, options),
          );
        },
      },
    },
  );

  await supabase.auth.getClaims();
  return response;
}

Three details carry the weight:

  • setAll rebuilds response. When the access token rotates, the new cookies must go on both the request (so Server Components in this same render see them) and the response (so the browser keeps them). Skip the second half and users are logged out at intervals that look random.
  • getClaims(), not getSession(). getSession() reads the cookie without validating it. getClaims() validates the JWT, so a forged cookie yields no claims. The getUser versus getClaims breakdown goes through all three methods.
  • There is no redirect() anywhere. The function returns response unconditionally.

One defect worth stating plainly: the non-null assertions on the two process.env reads mean that if those variables are unset the proxy throws, and because it runs on nearly every request, the whole site errors, marketing pages included. That is why this repo's CLAUDE.md forbids deploying main until the Supabase variables exist in the Vercel project. A production proxy should bail out early with NextResponse.next() when they are missing; ours has not needed to yet because the variables are set before the deploy, but it is the first thing I would change.

The matcher decides what the proxy covers

The matcher in src/proxy.ts is a negative lookahead that excludes build output, a few crawler files and a list of image extensions:

export const config = {
  matcher: [
    "/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|.*\\.(?:svg|png|jpg|jpeg|webp|gif|ico|webmanifest|html)$).*)",
  ],
};

I extracted that string from the file, anchored it with ^…$ the way Next does, and ran it against 21 paths:

PathProxy runs?
/, /templates, /templates/asoc-forge-landingYes
/dashboardYes
/api/download, /api/webhooks/lemonsqueezyYes
/blog/feed.xmlYes
/opengraph-image, /blog/<slug>/opengraph-imageYes
/fonts/x.woff2, /downloads/x.zip (hypothetical)Yes
/sitemap.xml, /robots.txt, /favicon.icoNo
/_next/static/…, /_next/imageNo
/brand/asoc-logo.webp, /images/x.jpg, /manifest.webmanifestNo
/googleabc123.html (Search Console verification)No

Result: 11 of the 21 run the proxy, 10 skip it. Two of the "Yes" rows are not what you would want by intent. The RSS feed and the generated OG cards are static-ish outputs that never read a session, but the extension list does not name .xml or the extensionless OG route, so each hit pays for a Supabase client construction. The matcher is an allow-by-default list of exclusions, which means every new asset type you add is covered until you notice. It is a cost problem rather than a security one, which is the point: because nothing in the proxy decides access, a matcher gap here costs milliseconds, not a hole.

Flip the design, put a redirect in the proxy, and the same gap becomes an unguarded route.

The layout guard: where the redirect lives

export default async function DashboardLayout({ children }: { children: ReactNode }) {
  const supabase = await createClient();
  const { data } = await supabase.auth.getClaims();
  if (!data?.claims) {
    redirect("/login?next=/dashboard");
  }
  // …render Header, children, Footer
}

This is src/app/dashboard/layout.tsx. Every route under /dashboard (the overview and settings) inherits it, so there is one check to audit rather than one per page. The same file sets robots: { index: false, follow: false }, because a private area should not be indexed.

Two caveats, both from Next's rendering model:

  • Layouts do not re-render on client-side navigation between sibling pages. The guard protects the first load and any full navigation, but a page that fetches its own data must still authorise that fetch. This is why src/app/dashboard/page.tsx calls getUser() itself.
  • A redirect is not an authorisation. It sends a human somewhere. It does not stop a request to the underlying data.

The route handler: fail closed, say little

src/app/api/download/route.ts is the check that cannot be skipped by navigating around a layout. It builds a dependency object for a framework-agnostic resolveDownload (373 lines in src/lib/download.ts) and wires the real session in:

async getSession() {
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) return null;
  return { userId: user.id, emailVerified: Boolean(user.email_confirmed_at) };
}

It uses getUser() rather than getClaims() here because email_confirmed_at is only exposed by getUser(), and the download gate needs it. The slot lookup then filters explicitly on user_id and status = 'active', because the admin client bypasses RLS and that filter is the boundary. The checks run in a fixed order: 401 if signed out, 403 if the email is unverified, 400 for a malformed request, 403 if no active slot covers the product, 429 over the rate limit, then a 302 to a signed URL. An infrastructure error fails closed with a generic 500 and the detail is logged server-side only.

The redirect target is an input

The login flow carries a ?next= parameter back through the OAuth callback. Anything that reads a redirect target from the URL needs to constrain it. src/lib/validation.ts does this with one regex:

const SAFE_NEXT_RE = /^\/(?![/\\])/;

export function safeNext(next: string | null | undefined): string {
  return next && SAFE_NEXT_RE.test(next) ? next : "/dashboard";
}

It accepts /dashboard/settings and rejects //evil.example, /\evil.example and https://evil.example, falling back to /dashboard. src/app/auth/callback/route.ts runs every next value through it before redirect(). For a broader treatment of protecting client routes, see React protected routes.

Mistakes and how they show up

MistakeSymptomFix
Redirecting anonymous users in the proxy and nowhere elseA path the matcher misses is openGuard in the layout, handler or query
Not rebuilding response inside setAllIntermittent logoutsReassign response when cookies rotate
Using getSession() for a decisionA forged cookie passesgetClaims() or getUser()
Unset env var with a ! assertion in the proxyWhole site errors, marketing pages tooReturn NextResponse.next() early
Matcher lists extensions but not .xml or OG routesNeedless client construction per feed or card hitAdd the exclusions you actually serve
Trusting ?next= as-isOpen redirect after loginAllow only a single leading slash
Assuming the layout re-runs on every navigationData fetched unauthorised on client transitionsAuthorise inside the page or action

Frequently asked questions

Is it safe to protect routes only in Next.js middleware? No. Treat it as a first filter. A matcher gap, a rewrite or a new route leaves the path unprotected, so enforce access where the data is read.

Should the proxy call the database? Avoid it. It sits on the hot path of every matched request, and a failure there takes down pages that have nothing to do with auth.

What is the difference between getUser() and getClaims() here? getClaims() validates the JWT and is enough for "is someone signed in". getUser() returns the full user, including email_confirmed_at, and this repo uses it where that field matters.

Does Next 16 still support middleware.ts? It resolves with a deprecation warning, but proxy.ts is the current name and keeping both files is a build error.

Templates with the session plumbing already wired

The proxy is one of the easiest files to get subtly wrong, because its failures look like intermittent logouts rather than errors. The templates below ship a marketing funnel with account scaffolding already in place, so this file is one you inherit rather than debug.

Keep reading

Tutorial10 min read

Next.js on AWS: Amplify, Lambda, ECS or EC2 — 8 Routes Decide It

Amplify, Lambda via SST, ECS Fargate, or EC2 — AWS has no framework-aware default. This build's own 428 static and 8 Node-runtime dynamic routes decide which compute layer actually fits.

Read more
Tutorial10 min read

The Next.js Bundle Analyzer Doesn't Run on Turbopack. Here's What Does.

ANALYZE=true writes nothing in Next 16, and the route table no longer prints First Load JS. The replacement, where it hides its output, and the 42 KB one constant was costing twelve routes.

Read more
Tutorial7 min read

A Next.js Calendar Grid, and the Timezone Bug It Has to Avoid

No calendar UI here, but this codebase already fixed the timezone bug that breaks most of them, twice. The UTC-safe date pattern, applied to a month grid.

Read more