Auth Middleware: The Layer Every Vibe-Coded App Needs
AI-built Next.js apps almost always protect routes in React, which is not protection at all. Here is the server-side middleware layer with Supabase SSR that actually stops bad actors, plus the...
The signup flow works. The login page posts to Supabase, the session cookie sets, and the dashboard renders with the user's name in the corner. You push to Vercel and go to bed. The next morning someone DMs you a screenshot of your admin page. They are not logged in. They opened devtools, deleted the if (!user) redirect('/login') line from the compiled JavaScript, and the page rendered. The data is real.
This is the most common security hole in apps that AI coding tools generate, and it shows up in almost every Lovable, Bolt, and v0 export we audit. The model learned that auth means checking user in a React component and redirecting. That check runs in the browser. Browsers belong to the user, including the attacker. Every byte of client-side JavaScript is a suggestion, not an enforcement. Real protection has to happen on a machine you control, which means the server, and in Next.js the right place to put it is middleware.
This article walks through the middleware layer that every production Next.js app using Supabase needs. The file itself is forty lines. The reasoning around it, including the Next.js 15 cookie bug that silently logs users out if you get it wrong, is what most tutorials leave out.
Why a client-side auth check is not protection
The first thing to internalise is what a client-side guard actually does. When you write something like this in a Server Component or a client layout:
"use client";
import { useUser } from "@/lib/auth";
import { redirect } from "next/navigation";
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const user = useUser();
if (!user) redirect("/login");
return <>{children}</>;
}
You have asked the browser to render a login redirect if no user is present. The browser can choose not to. A determined attacker does not even need to be determined. They open the React DevTools, find the component, set user to a fake object, and the redirect never fires. If the component is server-rendered, they can still intercept the network response and strip the redirect. They can curl the underlying API routes directly with a forged header. Nothing on the server has actually refused them.
The harder lesson is that UI gating and auth enforcement are two different jobs. Hiding the Admin nav link from non-admins is a UX nicety. Preventing a non-admin from reading admin data is a security requirement. A client check only does the first. You need server-side enforcement at two places: at the edge before the route renders, and inside the data layer where reads and writes happen. Middleware handles the first. Row Level Security on Supabase handles the second. Together they make the app actually safe. Alone, either one is a speed bump.
Middleware in Next.js runs on the edge runtime before any Server Component, Route Handler, or page starts executing. It sees every request, it can read cookies, it can redirect, and its decision is authoritative because the user's browser cannot skip it. That is why this is the layer that matters.
The shape of a Next.js middleware file
A Next.js middleware file lives at middleware.ts in the project root or inside src/. It exports a default async function that takes a NextRequest and returns a NextResponse, and a config object that tells Next which paths to run on.
The mistake most vibe-coded apps make is leaving the matcher at the default, which runs middleware on every single request including /_next/static/chunks/main.js and every image optimisation call. That is slow and breaks some Next.js internals. The correct matcher uses a negative lookahead to exclude static assets and Next's own paths, then lets you list public routes that should skip the auth check.
// middleware.ts
import { type NextRequest } from "next/server";
import { updateSession } from "@/utils/supabase/middleware";
export async function middleware(request: NextRequest) {
return await updateSession(request);
}
export const config = {
matcher: [
/*
* Match all request paths except:
* - _next/static (static files)
* - _next/image (image optimization)
* - favicon.ico, robots.txt, sitemap.xml
* - any file with an extension (images, fonts, etc.)
*/
"/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|woff|woff2)$).*)",
],
};
Put public routes inside the updateSession helper rather than in the matcher, because you still want middleware to run on /login to refresh the session cookie if one exists, you just do not want to redirect away from it.
The Supabase SSR cookie-refresh pattern
Supabase uses short-lived access tokens that rotate on a refresh token. The access token typically expires in one hour. When a user hits your app fifty-nine minutes into their session, Supabase issues a new access token and a new refresh token, and the client library writes them back into cookies. In a traditional SPA this happens in the browser. In a server-rendered Next.js app, cookie writes have to happen on the response, which means middleware is the only place in the request lifecycle that can read the old cookies, talk to Supabase, and write the new ones back in time for the Server Components downstream to see them.
This is why you cannot just read a cookie and call it a day. If middleware does not actively refresh the session, a user who stays on your app longer than an hour gets signed out in the middle of doing something, and they will not know why. The Supabase SSR package is built around this pattern, and the client factory takes a cookies adapter with getAll and setAll methods that you wire into both the NextRequest and the NextResponse.
// utils/supabase/middleware.ts
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
const PUBLIC_PATHS = [
"/login",
"/signup",
"/auth/callback",
"/auth/confirm",
"/api/webhooks/stripe",
"/api/webhooks/resend",
];
export async function updateSession(request: NextRequest) {
let response = NextResponse.next({
request: {
headers: request.headers,
},
});
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) => {
request.cookies.set(name, value);
});
response = NextResponse.next({
request: {
headers: request.headers,
},
});
cookiesToSet.forEach(({ name, value, options }) => {
response.cookies.set(name, value, options);
});
},
},
}
);
const {
data: { user },
} = await supabase.auth.getUser();
const path = request.nextUrl.pathname;
const isPublic = PUBLIC_PATHS.some((p) => path === p || path.startsWith(p + "/"));
if (!user && !isPublic) {
const url = request.nextUrl.clone();
url.pathname = "/login";
url.searchParams.set("next", path);
return NextResponse.redirect(url);
}
if (user && (path === "/login" || path === "/signup")) {
const url = request.nextUrl.clone();
url.pathname = "/dashboard";
return NextResponse.redirect(url);
}
return response;
}
Two things in that file are non-obvious and worth calling out. The first is the call to supabase.auth.getUser() rather than getSession(). Every Supabase example you will find uses getSession() because it is faster. getSession() only validates the JWT signature locally, which means a revoked or deleted user will still come back as authenticated until the token expires. getUser() makes a round trip to the Supabase auth server and confirms the user actually exists and is still valid. In middleware you want the strict check. It adds maybe twenty milliseconds at the edge and it is the difference between a revoked session actually being revoked and a zombie session lingering for an hour.
The second is the setAll callback, which does something that looks redundant. It writes the cookies twice, once onto the request and once onto the response, and rebuilds the response in between. That is the Next.js 15 cookie workaround and it is the single reason this code is more than twenty lines long.
The Next.js 15 cookie-not-setting bug
Starting in Next.js 15, the cookie handling between middleware and downstream Server Components changed in a way that breaks naive Supabase SSR setups. The symptom is that a user logs in, the middleware runs, Supabase refreshes the access token, the new cookie is set on the response, but the Server Component that renders next still sees the old cookie value. The old cookie points at an expired access token, so supabase.auth.getUser() in the Server Component returns null, and the user appears logged out on the very next page they navigate to.
The fix, which is now the documented pattern in the Supabase SSR docs, is to mutate request.cookies in addition to writing response.cookies. When Next.js 15 forwards the request to the Server Component, it reads cookies from the request object, not from the response. So you have to put the refreshed cookie on the request object before the request continues, and also on the response so the browser receives the updated value for the next request. That is what the setAll callback above does with its two forEach loops and the response rebuild in between. Skipping either loop looks fine in local dev with a long-lived test token and then breaks in production the moment a real user's token actually rotates.
A second gotcha from the same area is that you must return the same response object you mutated. If you construct a fresh NextResponse at the end of middleware, you lose all the Set-Cookie headers that Supabase wrote during the getUser call. The pattern of let response = NextResponse.next(...) at the top and return response at the bottom, with all mutations happening to that single variable, is load-bearing.
The public route exception list
The PUBLIC_PATHS array above exists because there are routes in every app that must not redirect unauthenticated users. The obvious ones are /login and /signup. The one people forget is the webhook route. If you have Stripe billing, Resend event logging, or any inbound webhook, those requests arrive with no session cookie because they come from a third-party server, not a browser. If middleware redirects them to /login, Stripe sees a 307, retries three times, marks the endpoint as failing, and eventually disables it. Your subscriptions stop syncing.
The fix is to explicitly list webhook paths as public. The signature check inside the webhook handler is what makes those routes safe. Stripe signs every payload with a secret only you know, and your handler verifies the signature before trusting the body. That is a different kind of auth, and it has to run, which means middleware has to let the request through.
There is also a /auth/callback route, which is where Supabase OAuth providers redirect after the user authorises. That page has to be public because at the moment it runs, the user does not yet have a session cookie. The callback code exchanges a one-time code in the URL for a session and sets the cookies. If middleware redirects it to /login first, the code exchange never happens and OAuth silently breaks.
Keep the list explicit and hand-maintained rather than using a pattern like "anything under /api". Too many apps have internal API routes that should absolutely be authenticated and accidentally get exposed by a blanket exclusion.
Defense in depth: middleware is necessary, not sufficient
Middleware catches a request at the edge and decides whether it is allowed to continue. That is a strong layer, but it is one layer. Three more belong in every Supabase app, and any one of them missing is a hole.
The first is Row Level Security on every table that holds user data. RLS is a Postgres feature that attaches a policy to a table so that queries only see rows matching a predicate. With Supabase, the predicate is usually auth.uid() = user_id, meaning a signed-in user can only read their own rows. RLS runs inside the database, which means even a compromised server with a leaked anon key cannot read data it should not. If middleware is the front door, RLS is the safe behind the bookshelf.
The second is a getUser() call inside Server Components and Server Actions that handle sensitive data. Middleware validates that a user exists, but it runs once per request and does not pass the user object to the page. Each Server Component should re-verify by calling supabase.auth.getUser() on its own client. This also means constructing a Supabase server client with cookies() from next/headers, which reads the freshly-set cookies that middleware just wrote. Building that helper in utils/supabase/server.ts and importing it everywhere is the standard pattern.
// utils/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// called from a Server Component; middleware will refresh
}
},
},
}
);
}
The third is explicit authorisation on mutations. A signed-in user is not automatically a signed-in user with permission to delete the record they are targeting. Every Route Handler and Server Action that writes data should check who the user is and whether they own or have a role on the resource. RLS enforces this at the database layer for you, but only if the mutation is a direct table write. If the mutation calls a Postgres function with SECURITY DEFINER, RLS is bypassed and the function itself has to check. Auditing your actions for this pattern is tedious and it is the part a real code review catches.
What you ship at the end of this
A middleware.ts that runs on every non-static path, a utils/supabase/middleware.ts that calls getUser and refreshes cookies with the Next.js 15 workaround, a small list of public paths including your webhook routes, a server-side Supabase client for Server Components, and RLS policies on every user-scoped table in the database. The attacker who opened devtools and deleted the if (!user) line now hits a redirect at the edge before any React code runs, and the API they tried to curl directly returns a 303 before their payload ever touches a handler.
If the middleware shape, the cookie workaround, or the RLS policies are the part of the stack you keep bouncing off, that is the exact layer WitsCode hardens for vibe-coded Next.js and Supabase apps. We audit the client-only guards the AI gave you, replace them with the middleware and RLS combination above, and leave you with an auth layer that holds up to the kind of poking real users do. Book the auth layer review →
Sources:
Get weekly field notes.
Practical writing on shipping products, straight to your inbox. No spam.
Need help with this?
MVP Development
We design and build web apps, MVPs, and SaaS products. Talk to us about what you are working on.
Talk to usWant to discuss vibe coders for your business?
Start a project and we'll talk through where you are, what's working, and the highest-leverage moves for the next 90 days.