DEV Community

Cover image for Supabase Authentication with Next.js 15 Complete Guide
Mahdi BEN RHOUMA
Mahdi BEN RHOUMA

Posted on Originally published at iloveblogs.blog

Supabase Authentication with Next.js 15 Complete Guide

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.

-- Users can only see their own data
CREATE POLICY "Users see own data"
ON profiles FOR SELECT
USING (auth.uid() = user_id);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

npx supabase gen types typescript --project-id your-project-ref > types/supabase.ts
Enter fullscreen mode Exit fullscreen mode

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!
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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.
          }
        },
      },
    }
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)$).*)',
  ],
}
Enter fullscreen mode Exit fullscreen mode

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:

'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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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"

// Check if user needs to confirm email
if (data.user && !data.user.email_confirmed_at) {
  setError('Please check your email to confirm your account')
}
Enter fullscreen mode Exit fullscreen mode

"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
/>
Enter fullscreen mode Exit fullscreen mode

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:

'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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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`)
}
Enter fullscreen mode Exit fullscreen mode

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:

// 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',
  },
})
Enter fullscreen mode Exit fullscreen mode

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}`,
    },
  })
}
Enter fullscreen mode Exit fullscreen mode

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:

const error = requestUrl.searchParams.get('error')
if (error === 'access_denied') {
  return NextResponse.redirect(`${origin}/login?error=cancelled`)
}
Enter fullscreen mode Exit fullscreen mode

"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>
  )
}
Enter fullscreen mode Exit fullscreen mode

Customize Magic Link Email

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:

<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>
Enter fullscreen mode Exit fullscreen mode

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.')
}
Enter fullscreen mode Exit fullscreen mode

Magic Link Security

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:

await supabase.auth.signInWithOtp({
  email,
  options: {
    emailRedirectTo: `${location.origin}/auth/callback`,
    // Custom expiration (in seconds)
    shouldCreateUser: false, // Don't create new users
  },
})
Enter fullscreen mode Exit fullscreen mode

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)$).*)',
  ],
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

2. Limit middleware scope:

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

3. Skip static assets:

// Already in the matcher, but be explicit
if (request.nextUrl.pathname.startsWith('/_next/')) {
  return NextResponse.next()
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

Logout from server:

'use server'

export async function logout() {
  const supabase = await createClient()
  await supabase.auth.signOut()
  redirect('/login')
}
Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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

const { data: { session }, error } = await supabase.auth.refreshSession()

if (error) {
  // Refresh failed, user needs to log in again
  router.push('/login')
}
Enter fullscreen mode Exit fullscreen mode

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,
    },
  }
)
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
// next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [
          {
            key: 'Strict-Transport-Security',
            value: 'max-age=31536000; includeSubDomains',
          },
        ],
      },
    ]
  },
}
Enter fullscreen mode Exit fullscreen mode
  1. Set secure cookie options
    Already handled by @supabase/ssr in production.

  2. 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')
  }
}
Enter fullscreen mode Exit fullscreen mode
  1. 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')
  }
}
Enter fullscreen mode Exit fullscreen mode

Further Reading

Related


Originally published at https://www.iloveblogs.blog

Top comments (0)