Supabase Auth

AuthenticationOpen SourceFirebase AlternativeJWTMFARow Level SecurityPostgreSQL

Library

Supabase Auth

Overview

Supabase Auth is the user authentication feature in the open-source Firebase alternative. Provides JWT, multi-factor authentication, OTP support, and Row Level Security (RLS) integration. Fully self-hostable avoiding vendor lock-in. One of the most promising authentication solutions for projects in 2025. Strongly recommended choice for new projects alongside NextAuth.js. Rapidly gaining support in open-source oriented developer community and optimized for database-driven application authentication.

Details

Supabase Auth 2025 is an authentication system deeply integrated with PostgreSQL database. JWT-based authentication provides consistent authentication experience on both client-side and server-side. Integration with Row Level Security (RLS) policies enables controlling authenticated user data access at row level. Multi-factor authentication (MFA) supports TOTP (Time-Based One Time Password) and manages security levels through Authenticator Assurance Level (AAL).

Key Features

  • Database Integrated Authentication: Authentication system fully integrated with PostgreSQL RLS
  • Complete JWT Support: Secure session management with access tokens and refresh tokens
  • Multi-Factor Authentication: Comprehensive MFA support with TOTP, SMS, Email OTP
  • Row Level Security: Fine-grained access control at database level
  • Social Login: Support for major providers like Google, GitHub, Discord
  • Self-Hostable: Completely open source enabling operation on own infrastructure

Pros and Cons

Pros

  • Complete open source eliminates vendor lock-in and enables long-term cost reduction
  • Deep PostgreSQL integration enables powerful database-driven authentication
  • RLS provides authorization control at database level rather than application level
  • Choice between self-hosting and managed service offers operational flexibility
  • Full TypeScript support provides type-safe development environment
  • Geographic routing support optimal for global-scale applications

Cons

  • PostgreSQL requirement limits database choice constraints
  • Operational and maintenance responsibility falls to development team when self-hosting
  • Limited enterprise features (audit logs, advanced RBAC)
  • Documentation and examples somewhat fewer compared to Firebase
  • Custom authentication flows require development effort for complex scenarios
  • Third-party library ecosystem still developing

Reference Pages

Code Examples

Basic Setup

# Install Supabase JavaScript client
npm install @supabase/supabase-js

# Additional packages for Next.js
npm install @supabase/auth-helpers-nextjs @supabase/auth-helpers-react

# React Hook Form (optional)
npm install react-hook-form @hookform/resolvers zod

Supabase Client Initial Configuration

// lib/supabase.ts
import { createClient } from '@supabase/supabase-js'

const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!

export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
  auth: {
    autoRefreshToken: true,
    persistSession: true,
    detectSessionInUrl: true
  }
})

// TypeScript type definitions generation
export type Database = {
  public: {
    Tables: {
      profiles: {
        Row: {
          id: string
          email: string
          full_name: string | null
          avatar_url: string | null
          created_at: string
        }
        Insert: {
          id: string
          email: string
          full_name?: string | null
          avatar_url?: string | null
        }
        Update: {
          full_name?: string | null
          avatar_url?: string | null
        }
      }
    }
  }
}

export const supabaseTyped = createClient<Database>(supabaseUrl, supabaseAnonKey)

Authentication Provider Setup (Next.js App Router)

// app/layout.tsx
import { createServerComponentClient } from '@supabase/auth-helpers-nextjs'
import { cookies } from 'next/headers'
import { AuthProvider } from '@/components/auth-provider'

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const supabase = createServerComponentClient({ cookies })
  const {
    data: { session },
  } = await supabase.auth.getSession()

  return (
    <html lang="en">
      <body>
        <AuthProvider session={session}>
          {children}
        </AuthProvider>
      </body>
    </html>
  )
}

// components/auth-provider.tsx
'use client'
import { createContext, useContext, useEffect, useState } from 'react'
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'
import type { Session, User } from '@supabase/supabase-js'

type AuthContextType = {
  session: Session | null
  user: User | null
  signOut: () => Promise<void>
}

const AuthContext = createContext<AuthContextType>({
  session: null,
  user: null,
  signOut: async () => {},
})

export function AuthProvider({ 
  children, 
  session: initialSession 
}: { 
  children: React.ReactNode
  session: Session | null 
}) {
  const [session, setSession] = useState<Session | null>(initialSession)
  const supabase = createClientComponentClient()

  useEffect(() => {
    const {
      data: { subscription },
    } = supabase.auth.onAuthStateChange((event, session) => {
      setSession(session)
    })

    return () => subscription.unsubscribe()
  }, [supabase])

  const signOut = async () => {
    await supabase.auth.signOut()
  }

  return (
    <AuthContext.Provider value={{ 
      session, 
      user: session?.user ?? null, 
      signOut 
    }}>
      {children}
    </AuthContext.Provider>
  )
}

export const useAuth = () => useContext(AuthContext)

Sign-up and Sign-in Forms

// components/auth-form.tsx
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'

const authSchema = z.object({
  email: z.string().email('Please enter a valid email address'),
  password: z.string().min(6, 'Password must be at least 6 characters'),
})

type AuthFormData = z.infer<typeof authSchema>

export function AuthForm({ mode }: { mode: 'sign-in' | 'sign-up' }) {
  const [isLoading, setIsLoading] = useState(false)
  const [message, setMessage] = useState('')
  const router = useRouter()
  const supabase = createClientComponentClient()

  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<AuthFormData>({
    resolver: zodResolver(authSchema),
  })

  const onSubmit = async (data: AuthFormData) => {
    setIsLoading(true)
    setMessage('')

    try {
      if (mode === 'sign-up') {
        const { error } = await supabase.auth.signUp({
          email: data.email,
          password: data.password,
          options: {
            emailRedirectTo: `${location.origin}/auth/callback`
          }
        })
        
        if (error) throw error
        setMessage('Confirmation email sent. Please check your email.')
      } else {
        const { error } = await supabase.auth.signInWithPassword({
          email: data.email,
          password: data.password,
        })
        
        if (error) throw error
        router.push('/dashboard')
        router.refresh()
      }
    } catch (error: any) {
      setMessage(error.message)
    } finally {
      setIsLoading(false)
    }
  }

  const handleSocialSignIn = async (provider: 'google' | 'github') => {
    const { error } = await supabase.auth.signInWithOAuth({
      provider,
      options: {
        redirectTo: `${location.origin}/auth/callback`
      }
    })
    
    if (error) {
      setMessage(error.message)
    }
  }

  return (
    <div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md">
      <h2 className="text-2xl font-bold mb-6 text-center">
        {mode === 'sign-up' ? 'Create Account' : 'Sign In'}
      </h2>
      
      <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
        <div>
          <label className="block text-sm font-medium mb-1">
            Email Address
          </label>
          <input
            type="email"
            {...register('email')}
            className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
          />
          {errors.email && (
            <p className="text-red-500 text-sm mt-1">{errors.email.message}</p>
          )}
        </div>

        <div>
          <label className="block text-sm font-medium mb-1">
            Password
          </label>
          <input
            type="password"
            {...register('password')}
            className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
          />
          {errors.password && (
            <p className="text-red-500 text-sm mt-1">{errors.password.message}</p>
          )}
        </div>

        <button
          type="submit"
          disabled={isLoading}
          className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50"
        >
          {isLoading ? 'Processing...' : mode === 'sign-up' ? 'Create Account' : 'Sign In'}
        </button>
      </form>

      <div className="mt-6">
        <div className="relative">
          <div className="absolute inset-0 flex items-center">
            <div className="w-full border-t border-gray-300" />
          </div>
          <div className="relative flex justify-center text-sm">
            <span className="px-2 bg-white text-gray-500">or</span>
          </div>
        </div>

        <div className="mt-6 space-y-3">
          <button
            onClick={() => handleSocialSignIn('google')}
            className="w-full flex items-center justify-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
          >
            Sign in with Google
          </button>
          
          <button
            onClick={() => handleSocialSignIn('github')}
            className="w-full flex items-center justify-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
          >
            Sign in with GitHub
          </button>
        </div>
      </div>

      {message && (
        <div className={`mt-4 p-3 rounded-md ${
          message.includes('Confirmation email') 
            ? 'bg-green-50 text-green-700 border border-green-200'
            : 'bg-red-50 text-red-700 border border-red-200'
        }`}>
          {message}
        </div>
      )}
    </div>
  )
}

Row Level Security (RLS) Policy Configuration

-- Create profiles table
CREATE TABLE profiles (
  id UUID REFERENCES auth.users ON DELETE CASCADE,
  email TEXT UNIQUE NOT NULL,
  full_name TEXT,
  avatar_url TEXT,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
  PRIMARY KEY (id)
);

-- Enable RLS
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;

-- Users can only view their own profile
CREATE POLICY "Users can view own profile" ON profiles
  FOR SELECT USING (auth.uid() = id);

-- Users can only update their own profile
CREATE POLICY "Users can update own profile" ON profiles
  FOR UPDATE USING (auth.uid() = id);

-- Users can only insert their own profile
CREATE POLICY "Users can insert own profile" ON profiles
  FOR INSERT WITH CHECK (auth.uid() = id);

-- Trigger for automatic profile creation
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
  INSERT INTO public.profiles (id, email, full_name)
  VALUES (new.id, new.email, new.raw_user_meta_data->>'full_name');
  RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

CREATE TRIGGER on_auth_user_created
  AFTER INSERT ON auth.users
  FOR EACH ROW EXECUTE PROCEDURE public.handle_new_user();

Multi-Factor Authentication (MFA) Implementation

// components/mfa-setup.tsx
'use client'
import { useState, useEffect } from 'react'
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'
import QRCode from 'qrcode'

export function MFASetup() {
  const [qrCode, setQrCode] = useState('')
  const [secret, setSecret] = useState('')
  const [verifyCode, setVerifyCode] = useState('')
  const [isEnabled, setIsEnabled] = useState(false)
  const [loading, setLoading] = useState(false)
  const supabase = createClientComponentClient()

  useEffect(() => {
    checkMFAStatus()
  }, [])

  const checkMFAStatus = async () => {
    const { data: { user } } = await supabase.auth.getUser()
    if (user) {
      const { data: factors } = await supabase.auth.mfa.listFactors()
      setIsEnabled(factors?.totp?.length > 0)
    }
  }

  const enrollMFA = async () => {
    setLoading(true)
    try {
      const { data, error } = await supabase.auth.mfa.enroll({
        factorType: 'totp',
        friendlyName: 'My Auth App',
      })

      if (error) throw error

      setSecret(data.totp.secret)
      
      // Generate QR code
      const qrCodeUrl = data.totp.qr_code
      const qrDataUrl = await QRCode.toDataURL(qrCodeUrl)
      setQrCode(qrDataUrl)
    } catch (error) {
      console.error('MFA enrollment error:', error)
    } finally {
      setLoading(false)
    }
  }

  const verifyAndEnable = async () => {
    if (!verifyCode) return

    setLoading(true)
    try {
      const { data, error } = await supabase.auth.mfa.challengeAndVerify({
        factorId: secret, // This would be the actual factor ID from enrollment
        code: verifyCode,
      })

      if (error) throw error

      setIsEnabled(true)
      alert('MFA successfully enabled!')
    } catch (error) {
      console.error('MFA verification error:', error)
      alert('Invalid code. Please try again.')
    } finally {
      setLoading(false)
    }
  }

  const unenrollMFA = async () => {
    setLoading(true)
    try {
      const { data: factors } = await supabase.auth.mfa.listFactors()
      if (factors?.totp?.length > 0) {
        const { error } = await supabase.auth.mfa.unenroll({
          factorId: factors.totp[0].id,
        })

        if (error) throw error

        setIsEnabled(false)
        setQrCode('')
        setSecret('')
        alert('MFA has been disabled.')
      }
    } catch (error) {
      console.error('MFA unenroll error:', error)
    } finally {
      setLoading(false)
    }
  }

  if (isEnabled) {
    return (
      <div className="p-6 bg-green-50 border border-green-200 rounded-lg">
        <h3 className="text-lg font-semibold text-green-800 mb-2">
          MFA is enabled ✓
        </h3>
        <p className="text-green-700 mb-4">
          Your account is protected with multi-factor authentication.
        </p>
        <button
          onClick={unenrollMFA}
          disabled={loading}
          className="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 disabled:opacity-50"
        >
          {loading ? 'Processing...' : 'Disable MFA'}
        </button>
      </div>
    )
  }

  return (
    <div className="p-6 border border-gray-200 rounded-lg">
      <h3 className="text-lg font-semibold mb-4">Multi-Factor Authentication Setup</h3>
      
      {!qrCode ? (
        <div>
          <p className="text-gray-600 mb-4">
            We recommend enabling multi-factor authentication to improve security.
          </p>
          <button
            onClick={enrollMFA}
            disabled={loading}
            className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
          >
            {loading ? 'Setting up...' : 'Setup MFA'}
          </button>
        </div>
      ) : (
        <div>
          <p className="text-sm text-gray-600 mb-4">
            Scan the QR code with your authenticator app and enter the 6-digit code displayed.
          </p>
          
          <div className="mb-4 text-center">
            <img src={qrCode} alt="MFA QR Code" className="mx-auto" />
          </div>
          
          <div className="mb-4">
            <label className="block text-sm font-medium mb-2">
              Authentication Code (6 digits)
            </label>
            <input
              type="text"
              value={verifyCode}
              onChange={(e) => setVerifyCode(e.target.value)}
              placeholder="123456"
              maxLength={6}
              className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
            />
          </div>
          
          <button
            onClick={verifyAndEnable}
            disabled={loading || verifyCode.length !== 6}
            className="w-full bg-green-600 text-white py-2 px-4 rounded-md hover:bg-green-700 disabled:opacity-50"
          >
            {loading ? 'Verifying...' : 'Enable MFA'}
          </button>
        </div>
      )}
    </div>
  )
}

Profile Management and Data Retrieval

// app/profile/page.tsx
import { createServerComponentClient } from '@supabase/auth-helpers-nextjs'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { ProfileForm } from '@/components/profile-form'

export default async function ProfilePage() {
  const supabase = createServerComponentClient({ cookies })
  
  const {
    data: { session },
  } = await supabase.auth.getSession()

  if (!session) {
    redirect('/auth/sign-in')
  }

  const { data: profile } = await supabase
    .from('profiles')
    .select('*')
    .eq('id', session.user.id)
    .single()

  return (
    <div className="max-w-2xl mx-auto p-6">
      <h1 className="text-2xl font-bold mb-6">Profile Settings</h1>
      <ProfileForm profile={profile} />
    </div>
  )
}

// components/profile-form.tsx
'use client'
import { useState } from 'react'
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'
import { useRouter } from 'next/navigation'

interface Profile {
  id: string
  email: string
  full_name: string | null
  avatar_url: string | null
}

export function ProfileForm({ profile }: { profile: Profile }) {
  const [fullName, setFullName] = useState(profile.full_name || '')
  const [avatarUrl, setAvatarUrl] = useState(profile.avatar_url || '')
  const [loading, setLoading] = useState(false)
  const supabase = createClientComponentClient()
  const router = useRouter()

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    setLoading(true)

    try {
      const { error } = await supabase
        .from('profiles')
        .update({
          full_name: fullName,
          avatar_url: avatarUrl,
          updated_at: new Date().toISOString(),
        })
        .eq('id', profile.id)

      if (error) throw error

      router.refresh()
      alert('Profile updated successfully!')
    } catch (error) {
      console.error('Profile update error:', error)
      alert('Failed to update profile.')
    } finally {
      setLoading(false)
    }
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-6">
      <div>
        <label className="block text-sm font-medium mb-2">
          Email Address
        </label>
        <input
          type="email"
          value={profile.email}
          disabled
          className="w-full px-3 py-2 bg-gray-100 border border-gray-300 rounded-md"
        />
        <p className="text-sm text-gray-500 mt-1">
          Email address cannot be changed
        </p>
      </div>

      <div>
        <label className="block text-sm font-medium mb-2">
          Full Name
        </label>
        <input
          type="text"
          value={fullName}
          onChange={(e) => setFullName(e.target.value)}
          className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
      </div>

      <div>
        <label className="block text-sm font-medium mb-2">
          Avatar URL
        </label>
        <input
          type="url"
          value={avatarUrl}
          onChange={(e) => setAvatarUrl(e.target.value)}
          placeholder="https://example.com/avatar.jpg"
          className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
      </div>

      <button
        type="submit"
        disabled={loading}
        className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50"
      >
        {loading ? 'Updating...' : 'Update Profile'}
      </button>
    </form>
  )
}