Supabase Auth

認証オープンソースFirebase代替JWTMFARow Level SecurityPostgreSQL

ライブラリ

Supabase Auth

概要

Supabase AuthはオープンソースのFirebase代替におけるユーザー認証機能です。JWT、多要素認証、OTPサポート、Row Level Security(RLS)との統合を提供し、完全にセルフホスト可能でベンダーロックインを回避できます。2025年のプロジェクトにおいて最有力な認証ソリューションの一つで、NextAuth.jsと並んで新規プロジェクトで強く推奨される選択肢となっています。オープンソース志向の開発者コミュニティで急速に支持が拡大し、データベース駆動アプリケーションでの認証に最適化されています。

詳細

Supabase Auth 2025はPostgreSQLデータベースと深く統合された認証システムです。JWTベースの認証により、クライアントサイドとサーバーサイドの両方で一貫した認証体験を提供します。Row Level Security(RLS)ポリシーとの統合により、認証されたユーザーのデータアクセスを行レベルで制御可能です。多要素認証(MFA)はTOTP(Time-Based One Time Password)をサポートし、Authenticator Assurance Level(AAL)によりセキュリティレベルを管理します。

主な特徴

  • データベース統合認証: PostgreSQLのRLSと完全統合した認証システム
  • JWT完全対応: アクセストークンとリフレッシュトークンによる安全なセッション管理
  • 多要素認証: TOTP、SMS、Email OTPによる包括的なMFA対応
  • Row Level Security: データベースレベルでの細粒度アクセス制御
  • ソーシャルログイン: Google、GitHub、Discord等の主要プロバイダー対応
  • セルフホスト可能: 完全にオープンソースで自社インフラでの運用が可能

メリット・デメリット

メリット

  • 完全オープンソースによりベンダーロックインがなく長期的なコスト削減
  • PostgreSQLとの深い統合により強力なデータベース駆動型認証を実現
  • RLSによりアプリケーションレベルではなくデータベースレベルでの認可制御
  • セルフホストとマネージドサービスを選択可能で運用の柔軟性が高い
  • TypeScript完全対応によりタイプセーフな開発環境を提供
  • 地理的ルーティング対応により世界規模のアプリケーションに最適

デメリット

  • PostgreSQLが必須のためデータベース選択の制約がある
  • セルフホスト時の運用・保守責任が開発チームに委ねられる
  • エンタープライズ向け機能(監査ログ、高度なRBAC)が限定的
  • ドキュメントや事例がFirebase等と比較してやや少ない
  • 複雑なカスタム認証フローには開発工数が必要
  • サードパーティライブラリのエコシステムがまだ発展途上

参考ページ

書き方の例

基本的なセットアップ

# Supabase JavaScript クライアントのインストール
npm install @supabase/supabase-js

# Next.js向けの追加パッケージ
npm install @supabase/auth-helpers-nextjs @supabase/auth-helpers-react

# React Hook Form(オプション)
npm install react-hook-form @hookform/resolvers zod

Supabaseクライアントの初期設定

// 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型定義の生成
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)

認証プロバイダーの設定(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="ja">
      <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)

サインアップとサインインフォーム

// 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('有効なメールアドレスを入力してください'),
  password: z.string().min(6, 'パスワードは6文字以上である必要があります'),
})

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('確認メールを送信しました。メールをご確認ください。')
      } 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' ? 'アカウント作成' : 'ログイン'}
      </h2>
      
      <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
        <div>
          <label className="block text-sm font-medium mb-1">
            メールアドレス
          </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">
            パスワード
          </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 ? '処理中...' : mode === 'sign-up' ? 'アカウント作成' : 'ログイン'}
        </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">または</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"
          >
            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"
          >
            GitHubでログイン
          </button>
        </div>
      </div>

      {message && (
        <div className={`mt-4 p-3 rounded-md ${
          message.includes('確認メール') 
            ? '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)ポリシーの設定

-- プロファイルテーブルの作成
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)
);

-- RLSの有効化
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;

-- ユーザーは自分のプロファイルのみ閲覧可能
CREATE POLICY "Users can view own profile" ON profiles
  FOR SELECT USING (auth.uid() = id);

-- ユーザーは自分のプロファイルのみ更新可能
CREATE POLICY "Users can update own profile" ON profiles
  FOR UPDATE USING (auth.uid() = id);

-- ユーザーは自分のプロファイルのみ挿入可能
CREATE POLICY "Users can insert own profile" ON profiles
  FOR INSERT WITH CHECK (auth.uid() = id);

-- プロファイル自動作成のトリガー
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();

多要素認証(MFA)の実装

// 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)
      
      // QRコードの生成
      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が正常に有効化されました!')
    } catch (error) {
      console.error('MFA verification error:', error)
      alert('コードが無効です。再度お試しください。')
    } 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が無効化されました。')
      }
    } 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が有効です ✓
        </h3>
        <p className="text-green-700 mb-4">
          アカウントは多要素認証で保護されています。
        </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 ? '処理中...' : 'MFAを無効化'}
        </button>
      </div>
    )
  }

  return (
    <div className="p-6 border border-gray-200 rounded-lg">
      <h3 className="text-lg font-semibold mb-4">多要素認証の設定</h3>
      
      {!qrCode ? (
        <div>
          <p className="text-gray-600 mb-4">
            セキュリティを向上させるため、多要素認証を有効にすることをお勧めします。
          </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 ? '設定中...' : 'MFAを設定'}
          </button>
        </div>
      ) : (
        <div>
          <p className="text-sm text-gray-600 mb-4">
            認証アプリでQRコードをスキャンし、表示された6桁のコードを入力してください。
          </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">
              認証コード(6桁)
            </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 ? '確認中...' : 'MFAを有効化'}
          </button>
        </div>
      )}
    </div>
  )
}

プロファイル管理とデータ取得

// 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">プロファイル設定</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('プロファイルが更新されました!')
    } catch (error) {
      console.error('Profile update error:', error)
      alert('更新に失敗しました。')
    } finally {
      setLoading(false)
    }
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-6">
      <div>
        <label className="block text-sm font-medium mb-2">
          メールアドレス
        </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">
          メールアドレスは変更できません
        </p>
      </div>

      <div>
        <label className="block text-sm font-medium mb-2">
          フルネーム
        </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">
          アバター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 ? '更新中...' : 'プロファイルを更新'}
      </button>
    </form>
  )
}