Supabase Auth

Intermediate 🕐 26 min Lesson 5 of 25
What you'll learn
  • Create a free Supabase project and locate the project URL, anon key, and service role key needed for this build
  • Explain the security difference between the anon key (safe for the browser, gated by Row Level Security) and the service role key (server-only, bypasses RLS)
  • Identify why Next.js needs three distinct Supabase clients -- browser, server, and middleware -- and what context each one runs in
  • Read and explain the cookies()-as-Promise pattern used in the server client
  • Wire a login/signup form using React 19's useActionState against Zod-validated Server Actions
  • Use getCurrentUser() as the single source of truth for the signed-in user across pages and queries

Before Any Code: Get Your Own Supabase Project

Everything in this lesson assumes you have a real Supabase project -- the reference build's auth code follows Supabase's documented patterns exactly, but it was never exercised against a live project in the environment it was built in (see README.md's "Known limitations" section). You need your own. This is a genuine prerequisite, not optional reading:

  1. Go to supabase.com and create a free account. The free tier requires no credit card and is enough for this entire track.
  2. Create a new project. Pick any region; save the database password it generates, you will want it later.
  3. In the project dashboard, go to Settings > API. You need three values from this page: the Project URL, the anon / public key, and the service_role key (click "Reveal" to see it).

The anon key is safe to expose to a browser -- Supabase's Row Level Security is what actually protects data, the anon key alone grants nothing. The service_role key bypasses Row Level Security entirely and must never reach client-side code; it only belongs in server-only environment variables. Put all three in your local .env file, matching .env.example in the reference repo:

NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key

The NEXT_PUBLIC_ prefix is a Next.js convention, not a Supabase one -- only variables with that prefix are bundled into client-side JavaScript. SUPABASE_SERVICE_ROLE_KEY deliberately has no prefix, so it stays server-only.

Why Three Different Supabase Clients

A Next.js App Router app runs code in genuinely different places -- the browser, the server during rendering, and the server inside middleware -- and each needs its own way of reading and writing the session cookie. The reference build's lib/supabase/ directory has one file per context.

lib/supabase/client.ts -- for Client Components (anything marked "use client"):

import { createBrowserClient } from "@supabase/ssr";

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
  );
}

lib/supabase/server.ts -- for Server Components, Server Actions, and Route Handlers, where session cookies are read through Next.js's cookies() API:

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 {
            // setAll was called from a Server Component -- safe to
            // ignore if middleware is refreshing the session (it is).
          }
        },
      },
    },
  );
}

Notice the cookies() call is awaited -- it returns a Promise in current Next.js, not a plain object. This is exactly the kind of API shape that changes between versions and is why AGENTS.md (lesson 2) tells the agent to check installed docs rather than assume.

lib/supabase/admin.ts -- the service-role client, for trusted server-only operations that must bypass Row Level Security, like looking up an owner's email for a transactional alert in lesson 7's data layer:

export function createAdminClient() {
  const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
  const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;

  if (!url || !serviceRoleKey) {
    throw new Error("Supabase admin client requires NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY");
  }

  return createClient(url, serviceRoleKey, {
    auth: { autoRefreshToken: false, persistSession: false },
  });
}

Refreshing the Session in Middleware

lib/supabase/middleware.ts contains updateSession(), called from the project's top-level proxy.ts on every matching request. Its job is to refresh the auth token before it expires and redirect signed-out users away from protected routes:

const isProtectedRoute = request.nextUrl.pathname.startsWith("/dashboard");

if (isProtectedRoute && !user) {
  const url = request.nextUrl.clone();
  url.pathname = "/login";
  return NextResponse.redirect(url);
}

The source comment above the getUser() call is worth taking seriously: "Avoid writing any logic between createServerClient and supabase.auth.getUser(). A simple mistake could make it very hard to debug issues with users being randomly logged out." Session refresh logic is fragile in exactly this way -- it works until one stray line of code between client creation and the user check breaks token refresh silently, and the resulting bug only shows up as "I keep getting logged out" days later.

The Login/Signup Form

app/login/page.tsx is a Client Component using React 19's useActionState to wire two Server Actions -- login and signup -- to one form, with built-in pending and error states:

const [loginState, loginAction, loginPending] = useActionState(login, initialState);
const [signupState, signupAction, signupPending] = useActionState(signup, initialState);

The actions themselves live in app/login/actions.ts and validate input with Zod before ever calling Supabase:

const credentialsSchema = z.object({
  email: z.string().email("Enter a valid email address"),
  password: z.string().min(8, "Password must be at least 8 characters"),
});

export async function login(_prevState: AuthFormState, formData: FormData) {
  const parsed = credentialsSchema.safeParse({
    email: formData.get("email"),
    password: formData.get("password"),
  });
  if (!parsed.success) {
    return { error: parsed.error.issues[0]?.message ?? "Invalid input" };
  }

  const supabase = await createClient();
  const { error } = await supabase.auth.signInWithPassword(parsed.data);
  if (error) return { error: error.message };

  revalidatePath("/", "layout");
  redirect("/dashboard");
}

Validate-then-authenticate-then-redirect is the same order every protected operation in this codebase follows, and it is worth internalizing now -- lesson 7's server-side mutations and the route handlers in later lessons all repeat this exact shape.

Reading the Current User

lib/auth.ts wraps the server client in one small, reusable function used everywhere a page or query needs to know who is signed in:

export async function getCurrentUser() {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  return user;
}

Every protected page in this app calls this and redirects to /login if it returns null -- you will see it again in lesson 6's schema design and lesson 7's query layer, both of which key everything off this one user id.

With real accounts and sessions in place, lesson 6 designs the schema those accounts actually own.

Key takeaways
  • A real, free Supabase project (no credit card required) is a hard prerequisite for this lesson -- the auth code follows documented patterns but was never run against a live project in the reference build's environment
  • The anon/public key is safe to expose to the browser because Row Level Security, not key secrecy, is what protects data; the service role key bypasses RLS entirely and must stay server-only
  • Next.js App Router needs separate Supabase clients for the browser, server components/actions, and middleware, because each context reads and writes session cookies differently
  • cookies() returns a Promise in current Next.js and must be awaited -- an API shape that has changed across versions, which is why checking installed docs over training memory matters
  • Middleware's updateSession() refreshes the session token on every request; writing logic between client creation and supabase.auth.getUser() risks silent, hard-to-debug random logouts
  • Every Server Action in this codebase validates with Zod, then authenticates, then redirects -- the same order repeated throughout the rest of the build