Supabase Authentication with Next.js 15 Complete Production Guide 2026
Developer Guide

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.

2026-04-27
45 min read
Supabase Authentication with Next.js 15 Complete Production Guide 2026

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#

  1. Why Supabase Auth for Next.js
  2. Project Setup and Installation
  3. Creating Supabase Clients
  4. Email and Password Authentication
  5. OAuth Authentication (Google, GitHub, Apple)
  6. Magic Link Authentication
  7. Middleware Route Protection
  8. Server-Side Authentication
  9. Client-Side Authentication
  10. Session Management
  11. Role-Based Access Control (RBAC)
  12. Multi-Tenant Patterns
  13. Production Security
  14. Error Handling and Debugging
  15. Testing Strategy
  16. Performance Optimization
  17. Migration Guides
  18. Troubleshooting
  19. 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.

sql
-- 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#

bash
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:

bash
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key

Find these values in your Supabase Dashboard:

  1. Go to Project Settings → API
  2. Copy the Project URL
  3. Copy the anon public key

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:

bash
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:

typescript
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:

typescript
'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:

typescript
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:

typescript
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:

typescript
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:

typescript
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.cookie directly
  • 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:

typescript
'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:

typescript
'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:

typescript
'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:

  1. Go to Authentication → Settings
  2. Find "Enable email confirmations"
  3. 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"

typescript
// 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:

typescript
<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#

  1. User clicks "Sign in with Google"
  2. Redirect to Google's login page
  3. User authorizes your app
  4. Google redirects back with authorization code
  5. Your callback route exchanges code for session
  6. User is logged in

Configure OAuth Providers#

Google OAuth Setup#

  1. Go to Google Cloud Console
  2. Create a new project or select existing
  3. Enable Google+ API
  4. Create OAuth 2.0 credentials
  5. Add authorized redirect URI: https://your-project.supabase.co/auth/v1/callback
  6. Copy Client ID and Client Secret

In Supabase Dashboard:

  1. Go to Authentication → Providers
  2. Enable Google
  3. Paste Client ID and Client Secret
  4. Save

GitHub OAuth Setup#

  1. Go to GitHub Settings → Developer settings → OAuth Apps
  2. Click "New OAuth App"
  3. Set Authorization callback URL: https://your-project.supabase.co/auth/v1/callback
  4. Copy Client ID and generate Client Secret

In Supabase Dashboard:

  1. Go to Authentication → Providers
  2. Enable GitHub
  3. Paste Client ID and Client Secret
  4. Save

Implement OAuth Sign In#

Create components/OAuthButtons.tsx:

typescript
'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:

typescript
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:

  1. Receives the authorization code from OAuth provider
  2. Exchanges it for a session
  3. Sets session cookies
  4. Redirects user to your app

Request Additional Scopes#

Request extra permissions from OAuth providers:

typescript
// 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:

typescript
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
  • redirectTo parameter in your code

"access_denied" User cancelled the OAuth flow. Handle gracefully:

typescript
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 links provide passwordless authentication. Users enter their email, receive a link, click it, and they're logged in. No password to remember or manage.

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

Create app/magic-link/page.tsx:

typescript
'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 the email template in Supabase Dashboard:

  1. Go to Authentication → Email Templates
  2. Select "Magic Link"
  3. Edit the template with your branding
  4. Use variables: .ConfirmationURL, .SiteURL

Example template:

text
<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:

typescript
if (error?.message?.includes('rate limit')) {
  setError('Too many requests. Please try again in an hour.')
}

Best practices:

  1. Links expire after 1 hour (configurable in dashboard)
  2. Links are single-use (can't be reused)
  3. Links are tied to the requesting IP (optional)
  4. Always use HTTPS in production

Configure expiration:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
// 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:

typescript
export const config = {
  matcher: [
    // Only run on specific routes
    '/dashboard/:path*',
    '/profile/:path*',
    '/api/:path*',
  ],
}

3. Skip static assets:

typescript
// 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#

typescript
// 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#

typescript
// 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

typescript
// ❌ 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

typescript
// ✅ 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:

typescript
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:

typescript
'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:

typescript
'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:

typescript
'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#

typescript
'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#

typescript
'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:

typescript
'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#

  1. User logs in
  2. Supabase creates a session with:
    • Access token (JWT, expires in 1 hour)
    • Refresh token (expires in 30 days)
  3. Tokens stored in cookies
  4. Access token sent with every request
  5. 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 token
  • sb-<project-ref>-auth-token-code-verifier - PKCE verifier

Cookie attributes:

  • HttpOnly: Prevents JavaScript access (security)
  • Secure: HTTPS only in production
  • SameSite=Lax: CSRF protection
  • Path=/: Available site-wide

Manual Session Refresh#

typescript
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:

typescript
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:

typescript
'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:

  1. Open your app in two tabs
  2. Log out in one tab
  3. Other tab should detect logout and redirect

Session Security#

Best practices:

  1. Always use HTTPS in production
typescript
// next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [
          {
            key: 'Strict-Transport-Security',
            value: 'max-age=31536000; includeSubDomains',
          },
        ],
      },
    ]
  },
}
  1. Set secure cookie options Already handled by @supabase/ssr in production.

  2. Implement session timeout

typescript
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')
  }
}
  1. Detect suspicious activity
typescript
// 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#

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

|

Have more questions? Contact us

One email a month — no fluff

RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.