Supabase Authentication with Next.js 15 Complete Production Guide 2026
Master Supabase Auth in Next.js 15 with this complete production guide. Email/password, OAuth, magic links, middleware protection, RLS integration, and advanced patterns for multi-tenant SaaS.
Supabase Authentication with Next.js 15: Complete Production Guide 2026#
Authentication is the foundation of every production application. Get it wrong and you expose user data, leak sessions, or spend weeks debugging cookie issues. Get it right and users log in seamlessly while their data stays protected.
This is the complete, production-ready guide to implementing Supabase authentication in Next.js 15. Not a basic tutorial—a comprehensive reference covering everything from initial setup to advanced patterns used in production SaaS applications.
What you'll build:
- Complete authentication system with email/password, OAuth (Google, GitHub), and magic links
- Middleware-based route protection that runs before any page loads
- Server-side authentication in Server Components and Server Actions
- Role-based access control (RBAC) with custom claims
- Multi-tenant authentication patterns
- Production security hardening
- Session management and refresh strategies
What makes this guide different:
- Production-ready code you can copy and deploy
- Every common error and how to fix it
- Advanced patterns not covered elsewhere
- Performance optimization strategies
- Complete testing approach
- Migration guides from other auth solutions
This guide targets Next.js 15 App Router with the latest @supabase/ssr package. All code examples are TypeScript and follow current best practices as of April 2026.
Table of Contents#
- Why Supabase Auth for Next.js
- Project Setup and Installation
- Creating Supabase Clients
- Email and Password Authentication
- OAuth Authentication (Google, GitHub, Apple)
- Magic Link Authentication
- Middleware Route Protection
- Server-Side Authentication
- Client-Side Authentication
- Session Management
- Role-Based Access Control (RBAC)
- Multi-Tenant Patterns
- Production Security
- Error Handling and Debugging
- Testing Strategy
- Performance Optimization
- Migration Guides
- Troubleshooting
- Production Checklist
Why Supabase Auth for Next.js#
Supabase Auth integrates deeply with PostgreSQL through Row Level Security (RLS), making it the natural choice if you're already using Supabase for your database.
Key Advantages#
1. Database-Level Security Authentication tokens flow directly to your database through RLS policies. No separate API layer needed to enforce authorization.
-- Users can only see their own data
CREATE POLICY "Users see own data"
ON profiles FOR SELECT
USING (auth.uid() = user_id);
2. Zero Backend Code No need to build login endpoints, password reset flows, or email verification. Supabase handles it all.
3. Free Tier Generosity
- 50,000 monthly active users free
- Unlimited OAuth providers
- Email templates included
- No credit card required
4. Next.js 15 Optimized
The @supabase/ssr package is built specifically for Next.js App Router with Server Components.
When to Choose Alternatives#
Use Auth.js (NextAuth) if:
- You need 50+ OAuth providers
- You're not using Supabase for your database
- You need SAML/Enterprise SSO
Use Clerk if:
- You want pre-built UI components
- You need advanced user management features
- Budget allows ($25/month minimum)
Use Firebase Auth if:
- You're already on Firebase
- You need phone auth in all countries
- You prefer Google's ecosystem
For most Next.js + Supabase apps, Supabase Auth is the simplest and most integrated choice.
Project Setup and Installation#
Prerequisites#
- Node.js 18.17 or later
- Next.js 15.0 or later
- A Supabase project (create one free)
Install Dependencies#
npm install @supabase/supabase-js @supabase/ssr
Important: Use @supabase/ssr, not the deprecated @supabase/auth-helpers-nextjs. The old package doesn't work correctly with Next.js 15.
Environment Variables#
Create .env.local in your project root:
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
Find these values in your Supabase Dashboard:
- Go to Project Settings → API
- Copy the Project URL
- Copy the
anonpublickey
Security Note: The anon key is safe to expose publicly. Row Level Security policies control what data users can access. Never expose your service_role key—it bypasses all RLS policies.
TypeScript Configuration#
Add Supabase types for full type safety:
npx supabase gen types typescript --project-id your-project-ref > types/supabase.ts
This generates TypeScript types from your database schema. Re-run this command whenever you change your database structure.
Creating Supabase Clients#
Next.js 15 has three execution contexts: browser (Client Components), server (Server Components/Actions), and middleware. Each needs a different Supabase client configuration.
Browser Client (Client Components)#
Create lib/supabase/client.ts:
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}
Use this in Client Components:
'use client'
import { createClient } from '@/lib/supabase/client'
import { useEffect, useState } from 'react'
export function UserProfile() {
const [user, setUser] = useState(null)
const supabase = createClient()
useEffect(() => {
supabase.auth.getUser().then(({ data }) => {
setUser(data.user)
})
}, [])
return <div>{user?.email}</div>
}
Server Client (Server Components & Actions)#
Create lib/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 {
// The `setAll` method was called from a Server Component.
// This can be ignored if you have middleware refreshing
// user sessions.
}
},
},
}
)
}
Use in Server Components:
import { createClient } from '@/lib/supabase/server'
export default async function ProfilePage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
redirect('/login')
}
return <div>Welcome {user.email}</div>
}
Middleware Client#
Create lib/supabase/middleware.ts:
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function updateSession(request: NextRequest) {
let supabaseResponse = 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, options }) => {
request.cookies.set(name, value)
supabaseResponse.cookies.set(name, value, options)
})
},
},
}
)
// Refresh session if expired
const { data: { user } } = await supabase.auth.getUser()
return supabaseResponse
}
Use in middleware.ts:
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)$).*)',
],
}
Why three different clients?
- Browser: Uses
document.cookiedirectly - Server: Uses Next.js
cookies()API - Middleware: Uses request/response cookies
Each context has different cookie access patterns. Using the wrong client causes session issues.
Email and Password Authentication#
The most common authentication method. Users sign up with email and password, verify their email, then log in.
Sign Up Flow#
Create app/signup/page.tsx:
'use client'
import { createClient } from '@/lib/supabase/client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
export default function SignUpPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const router = useRouter()
const supabase = createClient()
async function handleSignUp(e: React.FormEvent) {
e.preventDefault()
setError(null)
setLoading(true)
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${location.origin}/auth/callback`,
},
})
setLoading(false)
if (error) {
setError(error.message)
return
}
if (data.user && !data.user.confirmed_at) {
// Email confirmation required
setError('Check your email to confirm your account')
} else {
// Auto-confirmed (if email confirmation is disabled)
router.push('/dashboard')
}
}
return (
<form onSubmit={handleSignUp} className="max-w-md mx-auto p-6">
<h1 className="text-2xl font-bold mb-6">Sign Up</h1>
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded mb-4">
{error}
</div>
)}
<div className="mb-4">
<label className="block mb-2">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-3 py-2 border rounded"
/>
</div>
<div className="mb-6">
<label className="block mb-2">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
className="w-full px-3 py-2 border rounded"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Signing up...' : 'Sign Up'}
</button>
</form>
)
}
Sign In Flow#
Create app/login/page.tsx:
'use client'
import { createClient } from '@/lib/supabase/client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
export default function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const router = useRouter()
const supabase = createClient()
async function handleLogin(e: React.FormEvent) {
e.preventDefault()
setError(null)
setLoading(true)
const { error } = await supabase.auth.signInWithPassword({
email,
password,
})
setLoading(false)
if (error) {
setError(error.message)
return
}
router.push('/dashboard')
router.refresh() // Refresh server components
}
return (
<form onSubmit={handleLogin} className="max-w-md mx-auto p-6">
<h1 className="text-2xl font-bold mb-6">Log In</h1>
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded mb-4">
{error}
</div>
)}
<div className="mb-4">
<label className="block mb-2">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-3 py-2 border rounded"
/>
</div>
<div className="mb-6">
<label className="block mb-2">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-3 py-2 border rounded"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Logging in...' : 'Log In'}
</button>
</form>
)
}
Password Reset Flow#
Create app/reset-password/page.tsx:
'use client'
import { createClient } from '@/lib/supabase/client'
import { useState } from 'react'
export default function ResetPasswordPage() {
const [email, setEmail] = useState('')
const [sent, setSent] = useState(false)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const supabase = createClient()
async function handleReset(e: React.FormEvent) {
e.preventDefault()
setError(null)
setLoading(true)
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${location.origin}/update-password`,
})
setLoading(false)
if (error) {
setError(error.message)
return
}
setSent(true)
}
if (sent) {
return (
<div className="max-w-md mx-auto p-6">
<h1 className="text-2xl font-bold mb-4">Check Your Email</h1>
<p>We sent a password reset link to {email}</p>
</div>
)
}
return (
<form onSubmit={handleReset} className="max-w-md mx-auto p-6">
<h1 className="text-2xl font-bold mb-6">Reset Password</h1>
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded mb-4">
{error}
</div>
)}
<div className="mb-6">
<label className="block mb-2">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-3 py-2 border rounded"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Sending...' : 'Send Reset Link'}
</button>
</form>
)
}
Email Confirmation#
By default, Supabase requires email confirmation. Configure this in your Supabase Dashboard:
- Go to Authentication → Settings
- Find "Enable email confirmations"
- Toggle on/off based on your needs
Production recommendation: Keep email confirmation enabled to prevent fake signups and ensure valid email addresses.
Common Errors#
"Email not confirmed"
// Check if user needs to confirm email
if (data.user && !data.user.email_confirmed_at) {
setError('Please check your email to confirm your account')
}
"Invalid login credentials" This generic error appears for:
- Wrong password
- Email doesn't exist
- Account not confirmed
Supabase doesn't reveal which for security (prevents email enumeration attacks).
"Password should be at least 6 characters" Enforce minimum password length in your form:
<input
type="password"
minLength={6}
required
/>
OAuth Authentication (Google, GitHub, Apple)#
OAuth lets users sign in with existing accounts. No password to remember, faster signup, higher conversion rates.
OAuth Flow Overview#
- User clicks "Sign in with Google"
- Redirect to Google's login page
- User authorizes your app
- Google redirects back with authorization code
- Your callback route exchanges code for session
- User is logged in
Configure OAuth Providers#
Google OAuth Setup#
- Go to Google Cloud Console
- Create a new project or select existing
- Enable Google+ API
- Create OAuth 2.0 credentials
- Add authorized redirect URI:
https://your-project.supabase.co/auth/v1/callback - Copy Client ID and Client Secret
In Supabase Dashboard:
- Go to Authentication → Providers
- Enable Google
- Paste Client ID and Client Secret
- Save
GitHub OAuth Setup#
- Go to GitHub Settings → Developer settings → OAuth Apps
- Click "New OAuth App"
- Set Authorization callback URL:
https://your-project.supabase.co/auth/v1/callback - Copy Client ID and generate Client Secret
In Supabase Dashboard:
- Go to Authentication → Providers
- Enable GitHub
- Paste Client ID and Client Secret
- Save
Implement OAuth Sign In#
Create components/OAuthButtons.tsx:
'use client'
import { createClient } from '@/lib/supabase/client'
export function OAuthButtons() {
const supabase = createClient()
async function signInWithGoogle() {
await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${location.origin}/auth/callback`,
},
})
}
async function signInWithGitHub() {
await supabase.auth.signInWithOAuth({
provider: 'github',
options: {
redirectTo: `${location.origin}/auth/callback`,
},
})
}
return (
<div className="space-y-3">
<button
onClick={signInWithGoogle}
className="w-full flex items-center justify-center gap-3 px-4 py-2 border rounded hover:bg-gray-50"
>
<svg className="w-5 h-5" viewBox="0 0 24 24">
{/* Google icon SVG */}
</svg>
Continue with Google
</button>
<button
onClick={signInWithGitHub}
className="w-full flex items-center justify-center gap-3 px-4 py-2 border rounded hover:bg-gray-50"
>
<svg className="w-5 h-5" viewBox="0 0 24 24">
{/* GitHub icon SVG */}
</svg>
Continue with GitHub
</button>
</div>
)
}
OAuth Callback Handler#
Create app/auth/callback/route.ts:
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
export async function GET(request: Request) {
const requestUrl = new URL(request.url)
const code = requestUrl.searchParams.get('code')
const origin = requestUrl.origin
if (code) {
const supabase = await createClient()
await supabase.auth.exchangeCodeForSession(code)
}
// Redirect to dashboard or original destination
return NextResponse.redirect(`${origin}/dashboard`)
}
This route:
- Receives the authorization code from OAuth provider
- Exchanges it for a session
- Sets session cookies
- Redirects user to your app
Request Additional Scopes#
Request extra permissions from OAuth providers:
// Request email and profile from Google
await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${location.origin}/auth/callback`,
scopes: 'email profile',
},
})
// Request user repos from GitHub
await supabase.auth.signInWithOAuth({
provider: 'github',
options: {
redirectTo: `${location.origin}/auth/callback`,
scopes: 'user repo',
},
})
Access Provider Tokens#
After OAuth login, access the provider's access token:
const { data: { session } } = await supabase.auth.getSession()
if (session?.provider_token) {
// Use provider token to call their API
const response = await fetch('https://api.github.com/user/repos', {
headers: {
Authorization: `Bearer ${session.provider_token}`,
},
})
}
Common OAuth Errors#
"redirect_uri_mismatch" Your callback URL doesn't match what's configured in the OAuth provider. Double-check:
- Supabase callback URL in provider settings
redirectToparameter in your code
"access_denied" User cancelled the OAuth flow. Handle gracefully:
const error = requestUrl.searchParams.get('error')
if (error === 'access_denied') {
return NextResponse.redirect(`${origin}/login?error=cancelled`)
}
"invalid_request" Usually means missing or invalid OAuth credentials in Supabase Dashboard. Verify Client ID and Secret are correct.
Magic Link Authentication#
Magic links provide passwordless authentication. Users enter their email, receive a link, click it, and they're logged in. No password to remember or manage.
Why Magic Links?#
Advantages:
- Zero password management
- Higher conversion (no password requirements)
- More secure (no password to leak)
- Better UX on mobile
Disadvantages:
- Requires email access
- Slower than password (wait for email)
- Email deliverability issues
- Can't work offline
Implement Magic Links#
Create app/magic-link/page.tsx:
'use client'
import { createClient } from '@/lib/supabase/client'
import { useState } from 'react'
export default function MagicLinkPage() {
const [email, setEmail] = useState('')
const [sent, setSent] = useState(false)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const supabase = createClient()
async function handleMagicLink(e: React.FormEvent) {
e.preventDefault()
setError(null)
setLoading(true)
const { error } = await supabase.auth.signInWithOtp({
email,
options: {
emailRedirectTo: `${location.origin}/auth/callback`,
},
})
setLoading(false)
if (error) {
setError(error.message)
return
}
setSent(true)
}
if (sent) {
return (
<div className="max-w-md mx-auto p-6">
<h1 className="text-2xl font-bold mb-4">Check Your Email</h1>
<p className="mb-4">
We sent a magic link to <strong>{email}</strong>
</p>
<p className="text-sm text-gray-600">
Click the link in the email to sign in. The link expires in 1 hour.
</p>
</div>
)
}
return (
<form onSubmit={handleMagicLink} className="max-w-md mx-auto p-6">
<h1 className="text-2xl font-bold mb-6">Sign In with Magic Link</h1>
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded mb-4">
{error}
</div>
)}
<div className="mb-6">
<label className="block mb-2">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-3 py-2 border rounded"
placeholder="you@example.com"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Sending...' : 'Send Magic Link'}
</button>
<p className="mt-4 text-sm text-gray-600">
We'll email you a magic link for a password-free sign in.
</p>
</form>
)
}
Customize Magic Link Email#
Customize the email template in Supabase Dashboard:
- Go to Authentication → Email Templates
- Select "Magic Link"
- Edit the template with your branding
- Use variables:
.ConfirmationURL,.SiteURL
Example template:
<h2>Sign in to {{ .SiteURL }}</h2>
<p>Click the link below to sign in:</p>
<p><a href="{{ .ConfirmationURL }}">Sign In</a></p>
<p>This link expires in 1 hour.</p>
Rate Limiting#
Supabase rate limits magic link requests to prevent abuse:
- 4 requests per hour per email
- 60 requests per hour per IP
Handle rate limit errors:
if (error?.message?.includes('rate limit')) {
setError('Too many requests. Please try again in an hour.')
}
Magic Link Security#
Best practices:
- Links expire after 1 hour (configurable in dashboard)
- Links are single-use (can't be reused)
- Links are tied to the requesting IP (optional)
- Always use HTTPS in production
Configure expiration:
await supabase.auth.signInWithOtp({
email,
options: {
emailRedirectTo: `${location.origin}/auth/callback`,
// Custom expiration (in seconds)
shouldCreateUser: false, // Don't create new users
},
})
Middleware Route Protection#
Middleware runs before every request, making it perfect for authentication checks. Protect routes before any page code executes.
Basic Middleware Protection#
Create middleware.ts in your project root:
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
let supabaseResponse = 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, options }) => {
request.cookies.set(name, value)
supabaseResponse.cookies.set(name, value, options)
})
},
},
}
)
// Refresh session if expired
const { data: { user } } = await supabase.auth.getUser()
// Protect dashboard routes
if (request.nextUrl.pathname.startsWith('/dashboard') && !user) {
const redirectUrl = request.nextUrl.clone()
redirectUrl.pathname = '/login'
redirectUrl.searchParams.set('redirectedFrom', request.nextUrl.pathname)
return NextResponse.redirect(redirectUrl)
}
// Redirect authenticated users away from auth pages
if (request.nextUrl.pathname.startsWith('/login') && user) {
const redirectUrl = request.nextUrl.clone()
redirectUrl.pathname = '/dashboard'
return NextResponse.redirect(redirectUrl)
}
return supabaseResponse
}
export const config = {
matcher: [
/*
* Match all request paths except:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* - public files (public folder)
*/
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}
Advanced Middleware Patterns#
Protect specific routes:
const protectedRoutes = ['/dashboard', '/profile', '/settings']
const authRoutes = ['/login', '/signup']
export async function middleware(request: NextRequest) {
const path = request.nextUrl.pathname
const isProtectedRoute = protectedRoutes.some(route => path.startsWith(route))
const isAuthRoute = authRoutes.some(route => path.startsWith(route))
// ... create supabase client ...
const { data: { user } } = await supabase.auth.getUser()
// Redirect unauthenticated users from protected routes
if (isProtectedRoute && !user) {
return NextResponse.redirect(new URL('/login', request.url))
}
// Redirect authenticated users from auth routes
if (isAuthRoute && user) {
return NextResponse.redirect(new URL('/dashboard', request.url))
}
return supabaseResponse
}
Role-based protection:
export async function middleware(request: NextRequest) {
// ... create supabase client ...
const { data: { user } } = await supabase.auth.getUser()
// Admin-only routes
if (request.nextUrl.pathname.startsWith('/admin')) {
if (!user) {
return NextResponse.redirect(new URL('/login', request.url))
}
// Check user role from metadata
const userRole = user.user_metadata?.role
if (userRole !== 'admin') {
return NextResponse.redirect(new URL('/dashboard', request.url))
}
}
return supabaseResponse
}
Preserve redirect destination:
if (isProtectedRoute && !user) {
const redirectUrl = new URL('/login', request.url)
// Save where user was trying to go
redirectUrl.searchParams.set('redirectTo', request.nextUrl.pathname)
return NextResponse.redirect(redirectUrl)
}
// In your login page, redirect back after login:
const searchParams = useSearchParams()
const redirectTo = searchParams.get('redirectTo') || '/dashboard'
// After successful login:
router.push(redirectTo)
Middleware Performance#
Middleware runs on every request. Optimize for speed:
1. Cache user checks:
// Don't fetch user data in middleware
// Just check if session exists
const { data: { session } } = await supabase.auth.getSession()
const isAuthenticated = !!session
2. Limit middleware scope:
export const config = {
matcher: [
// Only run on specific routes
'/dashboard/:path*',
'/profile/:path*',
'/api/:path*',
],
}
3. Skip static assets:
// Already in the matcher, but be explicit
if (request.nextUrl.pathname.startsWith('/_next/')) {
return NextResponse.next()
}
Server-Side Authentication#
Server Components and Server Actions need server-side authentication. Access user data without client-side JavaScript.
Authentication in Server Components#
// app/dashboard/page.tsx
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
export default async function DashboardPage() {
const supabase = await createClient()
const { data: { user }, error } = await supabase.auth.getUser()
if (error || !user) {
redirect('/login')
}
// Fetch user-specific data
const { data: profile } = await supabase
.from('profiles')
.select('*')
.eq('id', user.id)
.single()
return (
<div>
<h1>Welcome {profile?.full_name || user.email}</h1>
<p>User ID: {user.id}</p>
</div>
)
}
Authentication in Server Actions#
// app/actions.ts
'use server'
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
export async function updateProfile(formData: FormData) {
const supabase = await createClient()
// Get authenticated user
const { data: { user }, error } = await supabase.auth.getUser()
if (error || !user) {
return { error: 'Unauthorized' }
}
const fullName = formData.get('fullName') as string
// Update profile
const { error: updateError } = await supabase
.from('profiles')
.update({ full_name: fullName })
.eq('id', user.id)
if (updateError) {
return { error: updateError.message }
}
revalidatePath('/dashboard')
return { success: true }
}
Fetch User Data Efficiently#
Bad: Multiple queries
// ❌ Don't do this
const { data: { user } } = await supabase.auth.getUser()
const { data: profile } = await supabase
.from('profiles')
.select('*')
.eq('id', user.id)
.single()
Good: Single query with RLS
// ✅ Do this - RLS automatically filters by user
const { data: profile } = await supabase
.from('profiles')
.select('*')
.single()
// RLS policy ensures user only sees their own profile
Server-Side Session Management#
Check session validity:
const { data: { session } } = await supabase.auth.getSession()
if (!session) {
redirect('/login')
}
// Check if session is expired
const expiresAt = session.expires_at
if (expiresAt && Date.now() / 1000 > expiresAt) {
// Session expired, refresh it
const { data: { session: newSession } } = await supabase.auth.refreshSession()
}
Logout from server:
'use server'
export async function logout() {
const supabase = await createClient()
await supabase.auth.signOut()
redirect('/login')
}
Client-Side Authentication#
Client Components handle interactive authentication UI. Use for login forms, user menus, and real-time auth state.
Auth State Listener#
Listen for auth changes in real-time:
'use client'
import { createClient } from '@/lib/supabase/client'
import { useEffect, useState } from 'react'
import type { User } from '@supabase/supabase-js'
export function useUser() {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const supabase = createClient()
useEffect(() => {
// Get initial session
supabase.auth.getSession().then(({ data: { session } }) => {
setUser(session?.user ?? null)
setLoading(false)
})
// Listen for auth changes
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
setUser(session?.user ?? null)
})
return () => subscription.unsubscribe()
}, [])
return { user, loading }
}
Use the hook:
'use client'
import { useUser } from '@/hooks/useUser'
export function UserMenu() {
const { user, loading } = useUser()
if (loading) {
return <div>Loading...</div>
}
if (!user) {
return <a href="/login">Sign In</a>
}
return (
<div>
<span>{user.email}</span>
<button onClick={handleLogout}>Sign Out</button>
</div>
)
}
Client-Side Logout#
'use client'
import { createClient } from '@/lib/supabase/client'
import { useRouter } from 'next/navigation'
export function LogoutButton() {
const router = useRouter()
const supabase = createClient()
async function handleLogout() {
await supabase.auth.signOut()
router.push('/login')
router.refresh() // Refresh server components
}
return (
<button onClick={handleLogout}>
Sign Out
</button>
)
}
Protected Client Component#
'use client'
import { useUser } from '@/hooks/useUser'
import { useRouter } from 'next/navigation'
import { useEffect } from 'react'
export function ProtectedComponent() {
const { user, loading } = useUser()
const router = useRouter()
useEffect(() => {
if (!loading && !user) {
router.push('/login')
}
}, [user, loading, router])
if (loading) {
return <div>Loading...</div>
}
if (!user) {
return null
}
return <div>Protected content for {user.email}</div>
}
Real-Time Auth Updates#
Handle auth events:
'use client'
import { createClient } from '@/lib/supabase/client'
import { useEffect } from 'react'
export function AuthListener() {
const supabase = createClient()
useEffect(() => {
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((event, session) => {
switch (event) {
case 'SIGNED_IN':
console.log('User signed in:', session?.user.email)
break
case 'SIGNED_OUT':
console.log('User signed out')
break
case 'TOKEN_REFRESHED':
console.log('Token refreshed')
break
case 'USER_UPDATED':
console.log('User updated')
break
case 'PASSWORD_RECOVERY':
console.log('Password recovery initiated')
break
}
})
return () => subscription.unsubscribe()
}, [])
return null
}
Session Management#
Sessions store authentication state. Understand how they work to build reliable auth.
How Sessions Work#
- User logs in
- Supabase creates a session with:
- Access token (JWT, expires in 1 hour)
- Refresh token (expires in 30 days)
- Tokens stored in cookies
- Access token sent with every request
- When access token expires, refresh token gets a new one
Session Storage#
Supabase stores sessions in cookies by default:
sb-<project-ref>-auth-token- Access tokensb-<project-ref>-auth-token-code-verifier- PKCE verifier
Cookie attributes:
HttpOnly: Prevents JavaScript access (security)Secure: HTTPS only in productionSameSite=Lax: CSRF protectionPath=/: Available site-wide
Manual Session Refresh#
const { data: { session }, error } = await supabase.auth.refreshSession()
if (error) {
// Refresh failed, user needs to log in again
router.push('/login')
}
Automatic Session Refresh#
Supabase automatically refreshes sessions:
- Checks every 30 seconds
- Refreshes if token expires in < 60 seconds
- Happens in background
Disable auto-refresh if needed:
const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
auth: {
autoRefreshToken: false, // Disable auto-refresh
persistSession: true,
},
}
)
Session Expiration Handling#
Handle expired sessions gracefully:
'use client'
import { createClient } from '@/lib/supabase/client'
import { useRouter } from 'next/navigation'
import { useEffect } from 'react'
export function SessionMonitor() {
const router = useRouter()
const supabase = createClient()
useEffect(() => {
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((event, session) => {
if (event === 'SIGNED_OUT' || !session) {
// Session expired or user logged out
router.push('/login?session=expired')
}
})
return () => subscription.unsubscribe()
}, [])
return null
}
Multi-Tab Session Sync#
Sessions sync across browser tabs automatically. When user logs out in one tab, all tabs update.
Test it:
- Open your app in two tabs
- Log out in one tab
- Other tab should detect logout and redirect
Session Security#
Best practices:
- Always use HTTPS in production
// next.config.js
module.exports = {
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'Strict-Transport-Security',
value: 'max-age=31536000; includeSubDomains',
},
],
},
]
},
}
-
Set secure cookie options Already handled by
@supabase/ssrin production. -
Implement session timeout
const MAX_SESSION_AGE = 24 * 60 * 60 * 1000 // 24 hours
const { data: { session } } = await supabase.auth.getSession()
if (session) {
const sessionAge = Date.now() - new Date(session.user.created_at).getTime()
if (sessionAge > MAX_SESSION_AGE) {
await supabase.auth.signOut()
redirect('/login?reason=timeout')
}
}
- Detect suspicious activity
// Store last known IP in user metadata
const { data: { session } } = await supabase.auth.getSession()
if (session) {
const currentIP = request.headers.get('x-forwarded-for')
const lastIP = session.user.user_metadata?.last_ip
if (lastIP && lastIP !== currentIP) {
// IP changed, require re-authentication
await supabase.auth.signOut()
redirect('/login?reason=security')
}
}
Further Reading#
- Supabase debugging and troubleshooting hub
- Supabase Auth + Middleware: The Complete Session Management Guide for Next.js 15
- Supabase Auth Error Codes Explained: same_password, weak_password, invalid_credentials (Fix Guide + TypeScript Cheat Sheet 2026)
- Handle Supabase Auth Errors in Next.js Middleware
- Supabase Auth Redirect Not Working in Next.js App Router: Exact Fix
Production Notes#
- Root cause to verify: compare local, preview, and production callback URLs, cookies, and server-side
auth.getUser()results. - Production fix pattern: keep the server client as the source of truth and avoid client-only session assumptions in protected routes.
- Verification step: sign in, hard-refresh a protected page, and confirm the server still sees the user.
Frequently Asked Questions
One email a month — no fluff
RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.
Related Guides
Advanced Authentication Patterns with Next.js and Supabase
Master advanced authentication patterns including OAuth, magic links, passwordless auth, custom JWT, multi-tenant authentication, and enterprise SSO integration with Next.js and Supabase.
Next.js App Router + Supabase SSR Session Management Deep Dive
Deep dive into Supabase SSR session management in Next.js App Router. Learn how cookies, middleware, and Server Components interact to keep users authenticated.
Supabase Auth + Middleware: The Complete Session Management Guide for Next.js 15
The complete guide to Supabase authentication and session management in Next.js 15. Middleware patterns, cookie handling, refresh tokens, MFA, redirect URLs, and the silent failures that ruin production auth.