Authentication
Last updated on 2026-09-07
The kit uses Supabase Auth with @supabase/ssr for cookie-based session management. It supports email/password login, Google OAuth, GitHub OAuth, password reset, and email confirmation.
Architecture Overview
Browser Middleware Server Components / Actions
│ │ │
│ Request │ │
├─────────────────────────►│ │
│ │ Read cookies, │
│ │ refresh JWT if expired │
│ │ │
│ │ Protected route? │
│ │ No user? → /login │
│ │ User on auth page? → / │
│ │ │
│ ├──────────────────────────►│
│ │ │ Read cookies via
│ │ │ server client
│ │ │ Call supabase.auth.getUser()
│ │ │
│◄─────────────────────────┤◄──────────────────────────┤
│ Response + Set-Cookie │ │
Three Supabase Clients
The kit creates three purpose-specific Supabase clients. Each uses a different mechanism for session handling:
1. Browser Client (lib/supabase/client.ts)
Used in Client Components (hooks, event handlers). Creates a browser client with createBrowserClient from @supabase/ssr:
"use client"
import { createBrowserClient } from "@supabase/ssr"
export function createClient() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
if (!url || !key) {
return createBrowserClient(
"https://placeholder.supabase.co",
"placeholder-key"
)
}
return createBrowserClient(url, key)
}
The placeholder fallback allows the app to render in demo mode without Supabase credentials.
2. Server Client (lib/supabase/server.ts)
Used in Server Components and Server Actions. Creates a server client with createServerClient that reads and writes cookies:
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 || "https://placeholder.supabase.co",
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || "placeholder-key",
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// The setAll method was called from a Server Component.
// This can be ignored if you have middleware refreshing sessions.
}
},
},
}
)
}
The try/catch in setAll handles the case where cookies are set from a Server Component (read-only context). The middleware handles session refresh separately.
3. Admin Client (lib/supabase/admin.ts)
Used for privileged operations that bypass RLS. Creates a standard Supabase client with the SUPABASE_SERVICE_ROLE_KEY:
import { createClient as createSupabaseClient } from "@supabase/supabase-js"
export function createAdminClient() {
return createSupabaseClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
auth: {
autoRefreshToken: false,
persistSession: false,
},
}
)
}
Warning: The service role key bypasses all RLS policies. Never expose it to the client. Only use it in Server Actions or API routes.
Middleware (middleware.ts)
The middleware runs on every request (except static assets) and handles two responsibilities:
- Session refresh -- refreshes the Supabase JWT if it's expired
- Route protection -- redirects unauthenticated users to
/login
import { type NextRequest } from "next/server"
import { updateSession } from "@/lib/supabase/middleware"
export async function middleware(request: NextRequest) {
return await updateSession(request)
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
}
Session Update Logic (lib/supabase/middleware.ts)
The middleware client creates a Supabase instance that reads request cookies and writes response cookies:
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request })
// Skip auth checks when Supabase is not configured (demo mode)
if (!process.env.NEXT_PUBLIC_SUPABASE_URL || !process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY) {
return supabaseResponse
}
const supabase = createServerClient(/* ... cookie handlers ... */)
// Wrap getUser in try-catch -- if Supabase is unreachable or credentials
// are invalid, treat the user as unauthenticated instead of crashing
let user = null
try {
const { data } = await supabase.auth.getUser()
user = data.user
} catch {
// Supabase unreachable -- continue as unauthenticated
}
const pathname = request.nextUrl.pathname
// Public routes that don't require authentication
const publicRoutes = ["/login", "/register", "/forgot-password"]
const isPublicRoute = publicRoutes.includes(pathname)
const isAuthRoute = pathname.startsWith("/auth/")
// Redirect unauthenticated users to login (all dashboard routes are protected)
if (!user && !isPublicRoute && !isAuthRoute) {
const url = request.nextUrl.clone()
url.pathname = "/login"
url.searchParams.set("redirect", pathname)
return NextResponse.redirect(url)
}
// Redirect authenticated users away from auth pages to dashboard
if (user && isPublicRoute) {
const url = request.nextUrl.clone()
url.pathname = "/"
return NextResponse.redirect(url)
}
return supabaseResponse
}
Key behaviors:
- Demo mode: If Supabase environment variables are not set, the middleware passes through without auth checks
- Redirect with return URL: When redirecting to
/login, the original path is preserved as a?redirect=query parameter - Authenticated redirect: Users already signed in are redirected away from login/register pages to the dashboard
Auth Routes
Public Pages
| Route | Purpose |
|---|---|
/login |
Email/password and OAuth sign-in |
/register |
New user sign-up |
/forgot-password |
Password reset request |
OAuth Callback (app/(auth)/auth/callback/route.ts)
Handles the redirect from OAuth providers (Google, GitHub). Exchanges the authorization code for a session:
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url)
const code = searchParams.get("code")
const redirect = searchParams.get("redirect") || "/"
if (code) {
const supabase = createServerClient(/* ... */)
const { error } = await supabase.auth.exchangeCodeForSession(code)
if (!error) {
return NextResponse.redirect(`${origin}${redirect}`)
}
}
return NextResponse.redirect(`${origin}/login?error=auth_callback_error`)
}
Email Confirmation (app/(auth)/auth/confirm/route.ts)
Handles the email verification link clicked from confirmation emails. Verifies the OTP token:
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url)
const token_hash = searchParams.get("token_hash")
const type = searchParams.get("type") as EmailOtpType | null
if (token_hash && type) {
const supabase = createServerClient(/* ... */)
const { error } = await supabase.auth.verifyOtp({ type, token_hash })
if (!error) {
return NextResponse.redirect(`${origin}/`)
}
}
return NextResponse.redirect(`${origin}/login?error=confirmation_failed`)
}
Server Actions (lib/actions/auth.ts)
All auth mutations are implemented as Server Actions:
| Action | Description |
|---|---|
signIn(email, password) |
Email/password sign-in with signInWithPassword() |
signUp(email, password, fullName) |
New user registration with full_name metadata |
signOut() |
Signs out and redirects to /login |
resetPassword(email) |
Sends password reset email with redirect to /settings/password |
updatePassword(newPassword) |
Updates password for authenticated user |
signInWithProvider(provider) |
Initiates Google or GitHub OAuth flow via signInWithOAuth() |
Sign-Up Flow
- User submits the register form
signUp()Server Action callssupabase.auth.signUp()withfull_namein thedataoption- Supabase creates the
auth.usersrow - The
on_auth_user_createdtrigger fires and creates aprofilesrow with the user's name, email, and avatar URL - If email confirmation is enabled, the user receives a verification email with a link to
/auth/confirm - On confirmation, the OTP is verified and the user is redirected to the dashboard
OAuth Flow
- User clicks "Sign in with Google" or "Sign in with GitHub"
signInWithProvider()callssupabase.auth.signInWithOAuth()withredirectTo: ${SITE_URL}/auth/callback- User is redirected to the OAuth provider
- After authorization, the provider redirects to
/auth/callbackwith acodeparameter - The callback route exchanges the code for a session
- The
on_auth_user_createdtrigger fires and creates a profile using thefull_nameandavatar_urlfrom the OAuth metadata - User is redirected to the dashboard
Profile Auto-Creation Trigger
When any user signs up (email or OAuth), a database trigger automatically creates their profile:
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$
BEGIN
INSERT INTO public.profiles (id, full_name, email, avatar_url)
VALUES (
new.id,
COALESCE(new.raw_user_meta_data->>'full_name', split_part(new.email, '@', 1)),
new.email,
COALESCE(new.raw_user_meta_data->>'avatar_url', '')
);
RETURN new;
END;
$$;
The trigger:
- Uses
SECURITY DEFINERto bypass RLS (since the user doesn't have a profile yet when this runs) - Falls back to the email prefix if no
full_nameis provided - Pulls the avatar URL from OAuth metadata (available for Google and GitHub)
Customizing Auth
Adding a New OAuth Provider
- Enable the provider in Supabase Dashboard > Auth > Providers
- Add the provider to the
signInWithProvider()Server Action:
export async function signInWithProvider(provider: "google" | "github" | "your-provider") {
// ... same implementation
}
- Add a button in the login/register forms that calls the action
Disabling Email Confirmation
In Supabase Dashboard > Auth > Settings, disable "Enable email confirmations". Users will be logged in immediately after sign-up.
Adding Protected API Routes
Use the server client in any API route:
import { createClient } from "@/lib/supabase/server"
export async function GET() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return new Response("Unauthorized", { status: 401 })
}
// ... authenticated logic
}