Strip away the dashboard and the SDK and Supabase Auth is two things: a Postgres schema and a token service. When someone signs up, a row appears in auth.users — a real table in your database, sitting next to your own tables. You can foreign-key a profiles table to it, join against it, and export it the day you decide to leave. That is a quiet but important difference from auth providers that keep user records on their side of the fence. With Supabase, you own the data.
A successful login produces two tokens. The access token is a JWT — a signed, self-contained statement saying "this is user X, valid until Y" — and it is deliberately short-lived, one hour by default. The refresh token is long-lived and single-use; its only job is to mint the next access token when the current one expires. In a browser-only app, both could sit in localStorage. In the App Router, where server components render your page before any JavaScript runs in the browser, they have to travel in cookies, because cookies are the only storage both the browser and the server can read.
That last sentence is the whole article in miniature. Nearly everything that goes wrong with Supabase auth in Next.js is some version of one runtime reading cookies that another runtime failed to write.
The setup is ordinary. You need a Supabase project — the URL and anon key from project settings go into NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY in .env.local — and two packages:
npx create-next-app@latest my-app --typescript --tailwind --app
cd my-app
npm install @supabase/supabase-js @supabase/ssrThe @supabase/ssr package exists because the default supabase-js client assumes localStorage, which the server does not have. It gives you two factory functions, and the discipline of this whole integration is calling the right one in the right place.
import { createBrowserClient } from "@supabase/ssr";
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}The browser client serves client components — login forms, sign-out buttons, realtime subscriptions. It talks to document.cookie directly and needs nothing beyond the environment variables.
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) {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
},
},
}
);
}The server client is different in two ways that matter. It is created fresh on every request — never cache it in a module-level variable, because it wraps that specific request's cookies. And its setAll has a known blind spot: called from a server component, the write silently fails, because Next.js forbids cookie writes once a component has started rendering. Server components can read the session but can never update it. Which raises the obvious question — when the access token expires mid-session, who writes the refreshed one?
The middleware is what keeps people logged in
Middleware. It runs before every matched request, it is allowed to set cookies on the response, and in this architecture it has exactly one load-bearing job: refresh the session and pass it along.
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function middleware(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() {
return 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)
);
},
},
}
);
const { data: { user } } = await supabase.auth.getUser();
if (!user && request.nextUrl.pathname.startsWith("/dashboard")) {
return NextResponse.redirect(new URL("/auth/login", request.url));
}
return response;
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};The getUser() call does the real work. If the access token has expired, the SDK spends the refresh token to obtain a new pair, and the setAll callback writes the fresh cookies onto both the request — so server components rendering after the middleware see the new session — and the response, so the browser stores it for next time. The redirect away from /dashboard is a convenience layered on top; the refresh is the part you cannot live without.
warning
If your middleware matcher only covers protected routes like /dashboard, sessions stop refreshing everywhere else. The failure shows up roughly an hour after login and looks random: a user reads your marketing pages for a while, their access token quietly expires, no middleware runs to renew it, and the next server component treats them as logged out. Match every route that is not a static asset.
With the plumbing in place, the flows themselves are short. Email and password sign-up works well as a server action:
"use server";
import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
export async function signUp(formData: FormData) {
const supabase = await createClient();
const email = formData.get("email") as string;
const password = formData.get("password") as string;
const { error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
},
});
if (error) {
return redirect("/auth/signup?error=" + encodeURIComponent(error.message));
}
return redirect("/auth/verify-email");
}By default Supabase sends a confirmation email, and the link inside it points at a callback route in your app. OAuth follows the same shape: you ask Supabase for a provider URL, send the user there, and the provider eventually returns them with a one-time code.
"use server";
import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
export async function signInWithGoogle() {
const supabase = await createClient();
const { data, error } = await supabase.auth.signInWithOAuth({
provider: "google",
options: {
redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
},
});
if (error) throw error;
return redirect(data.url);
}import { createClient } from "@/lib/supabase/server";
import { NextResponse } from "next/server";
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get("code");
if (code) {
const supabase = await createClient();
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (!error) {
return NextResponse.redirect(`${origin}/dashboard`);
}
}
return NextResponse.redirect(`${origin}/auth/login?error=auth_failed`);
}The callback route is the piece people forget. exchangeCodeForSession turns the one-time code into the access and refresh tokens and writes the cookies — it is the moment the user actually becomes logged in. Each OAuth provider also needs its client ID, secret, and redirect URL configured in both the provider's console and the Supabase dashboard. When those two disagree, the error message is vague enough that setting them carefully the first time beats debugging them later.
Magic links, honestly
Magic links (signInWithOtp) ride the same callback route, and they are tempting because they delete the password problem entirely: nothing to forget, nothing to leak, no reset flow to build. The costs are real, though. Every single login now depends on email deliverability and speed, which you do not control. People who read mail on their phone but work on a laptop get a clumsy cross-device dance. And corporate mail scanners sometimes follow links before the user does, consuming the one-time token — Supabase mitigates this by putting a six-digit code in the same email as a fallback. I treat magic links as a good secondary option next to OAuth, not as the sole login method for anything people open daily.
With middleware keeping the session fresh, protecting a page is a matter of asking who is there and acting on the answer:
import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) redirect("/auth/login");
const { data: profile } = await supabase
.from("profiles")
.select("*")
.eq("user_id", user.id)
.single();
return <h1>Welcome, {profile?.full_name ?? user.email}</h1>;
}warning
On the server, authenticate with supabase.auth.getUser(), never with getSession() alone. getSession() reads the JWT out of the cookie and trusts it without contacting Supabase — and anything in a cookie can be forged by whoever sent the request. getUser() revalidates the token with the auth server on every call. The Supabase docs say this explicitly, and it is still the most commonly ignored line in them.
Route handlers are protected the same way: create the server client, call getUser(), return a 401 when it comes back null. Do this even on routes your middleware already guards — matchers drift as an app grows, and the check inside the handler is the one that cannot be bypassed.
Signing out is the one flow that belongs in a client component, with one detail worth noticing:
"use client";
import { createClient } from "@/lib/supabase/client";
import { useRouter } from "next/navigation";
export default function SignOutButton() {
const router = useRouter();
const handleSignOut = async () => {
const supabase = createClient();
await supabase.auth.signOut();
router.push("/");
router.refresh();
};
return <button onClick={handleSignOut}>Sign out</button>;
}The router.refresh() is not decoration. Your server components rendered with the old session, and Next.js will happily keep showing that cached output — a header still greeting a user who just logged out. Refreshing forces the server to re-render with the now-empty session.
Everything above is authentication — establishing who the user is. Authorization, deciding what they may touch, belongs in the database. This matters more with Supabase than elsewhere, because Supabase exposes your tables through an auto-generated API and the anon key ships inside your client bundle to every visitor. Row Level Security policies are what stand between that public key and your rows.
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view own profile"
ON profiles FOR SELECT
USING (auth.uid() = user_id);
CREATE POLICY "Users can update own profile"
ON profiles FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can create own profile"
ON profiles FOR INSERT
WITH CHECK (auth.uid() = user_id);auth.uid() resolves to the id of the user whose JWT accompanied the request. Because policies execute inside Postgres on every query, they hold even when your application code has a bug — a missing where clause, an IDOR in a route handler, a query you wrote at midnight. The database refuses to return rows the user does not own. No amount of careful application code gives you that guarantee on its own.
warning
A table created through the Supabase dashboard gets RLS enabled automatically. A table created with raw SQL or a migration does not, because Postgres itself defaults to RLS off — and that table is then readable and writable by anyone holding your anon key, which is everyone who has loaded your site. Check the table list for the RLS-disabled warning before every launch.
If your data already lives in Supabase, use Supabase Auth and stop deliberating. The session token is the same JWT that RLS policies check, the user table sits next to your own data, and the email flows are hosted for you. Bolting NextAuth onto a Supabase database means hand-building the bridge that forwards your session into Postgres so RLS still works — solving a problem you created.
NextAuth (now Auth.js) wins in the inverse situation. It is a library, not a service: it runs wherever your Next.js app runs, supports a very long list of OAuth providers, and gives you full control over the session shape and the database it persists to. If your stack is plain Postgres with Prisma — as this site's is — NextAuth keeps authentication inside your codebase with no external service sitting in the login path. That independence is worth more than any single feature.
Neither choice is wrong. The expensive mistake is migrating between them casually: moving live users means exporting password hashes where possible and forcing resets where it is not, and your users pay for the indecision.
Auth bugs are the kind users do not report — they just leave. Before launch, walk this list:
- Middleware matcher covers every route that is not a static asset, not only the protected ones
- Every server-side check — pages, layouts, route handlers, server actions — uses getUser(), not getSession()
- RLS is enabled on every table in the exposed schema, with policies tested using two real accounts trying to read each other's data
- Email confirmation is on, and custom SMTP is configured — the built-in email service is rate-limited to a handful of messages per hour and is not meant for production
- OAuth redirect URLs and the Site URL in your Supabase auth settings point at the production domain, not localhost
The two-account test on that list is the one I would not skip. Create two users, sign in as the first, and try to read and update the second user's rows — through the UI, then directly through a route handler. When the database itself turns you away, you have not just configured Supabase auth; you understand where every layer of the system draws its line. That understanding is what carries over to the next real project you build, whatever auth it ends up using.
