시리즈 목차 1편 – Supabase란 무엇인가? Firebase와 제대로 비교해보기 2편 – 데이터베이스 & 자동 API (REST/GraphQL) 3편 👉 인증(Auth) — 소셜 로그인, JWT, MFA, SSO (현재 글) 4편 – RLS 보안 — Row Level Security 완벽 가이드 …
들어가며
인증(Authentication)은 모든 서비스의 출발점입니다. 잘못 구현하면 보안 구멍이 생기고, 너무 복잡하게 만들면 사용자 이탈이 생깁니다. Supabase Auth는 이 균형을 잘 잡아주는 도구입니다.
Supabase Auth는 GoTrue를 기반으로 한 JWT 인증 시스템으로, 다양한 로그인 방식을 지원하면서도 PostgreSQL의 Row Level Security(RLS)와 긴밀하게 통합됩니다. 인증된 사용자의 정보가 곧 DB 접근 권한의 기준이 되는 구조입니다.
이번 편에서 다룰 내용:
- Supabase Auth 아키텍처와 JWT 동작 원리
- 이메일/비밀번호 로그인 구현
- Magic Link (이메일 OTP) 로그인
- 소셜 로그인 (Google, GitHub, Kakao 등)
- 전화번호 / SMS OTP 로그인
- 다중 인증 (MFA) — TOTP, 전화 인증
- SAML SSO (기업용)
- Next.js App Router에서의 세션 관리와 라우트 보호
- 2025년 신기능: OAuth 2.1 Identity Provider
Supabase Auth 아키텍처
JWT 동작 흐름
Supabase Auth는 JWT(JSON Web Token) 기반으로 동작합니다. 사용자가 로그인하면 두 가지 토큰이 발급됩니다.
| 토큰 | 유효 기간 | 역할 |
|---|---|---|
| Access Token | 기본 1시간 | API 요청 인증, RLS 정책 적용 |
| Refresh Token | 기본 60일 | Access Token 재발급 |
Access Token의 페이로드에는 다음 정보가 담깁니다:
{
"aud": "authenticated",
"exp": 1720000000,
"sub": "user-uuid-here",
"email": "user@example.com",
"role": "authenticated",
"aal": "aal1",
"app_metadata": { "provider": "google" },
"user_metadata": { "full_name": "홍길동" }
}
이 JWT가 Supabase API 요청에 자동으로 포함되어, RLS 정책에서 auth.uid()로 현재 사용자 ID를 확인할 수 있게 됩니다.
세션 저장 방식 (Next.js App Router)
@supabase/ssr 패키지는 세션을 쿠키에 저장합니다. localStorage 방식이 아닌 쿠키 기반이기 때문에 Server Component에서도 세션 정보를 읽을 수 있습니다.
클라이언트 요청
→ middleware.ts에서 쿠키 읽기 → 세션 갱신
→ Server Component에서 cookies()로 세션 확인
→ Client Component에서 onAuthStateChange 구독
프로젝트 기본 세팅
1편에서 설명한 lib/supabase/server.ts, lib/supabase/client.ts, middleware.ts가 이미 세팅되어 있다고 가정합니다. 미들웨어는 모든 요청에서 세션을 자동 갱신하므로 반드시 설정해야 합니다.
인증 콜백 라우트
소셜 로그인, 매직 링크 등 OAuth 흐름에서는 콜백 URL이 필요합니다.
// app/auth/callback/route.ts
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url)
const code = searchParams.get('code')
const next = searchParams.get('next') ?? '/'
if (code) {
const supabase = await createClient()
const { error } = await supabase.auth.exchangeCodeForSession(code)
if (!error) {
return NextResponse.redirect(`${origin}${next}`)
}
}
return NextResponse.redirect(`${origin}/auth/error`)
}
Supabase 대시보드 → Authentication → URL Configuration에 리다이렉트 URL을 추가합니다:
http://localhost:3000/auth/callback
https://yourdomain.com/auth/callback
이메일 / 비밀번호 로그인
가장 기본적인 인증 방식입니다.
회원가입
// app/auth/signup/actions.ts
'use server'
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
export async function signUp(formData: FormData) {
const supabase = await createClient()
const email = formData.get('email') as string
const password = formData.get('password') as string
const { error } = await supabase.auth.signUp({
email,
password,
options: {
// 회원가입 후 이메일 인증 링크로 리다이렉트될 URL
emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
// 추가 메타데이터
data: {
full_name: formData.get('full_name') as string,
},
},
})
if (error) {
return { error: error.message }
}
// 이메일 인증이 활성화된 경우
redirect('/auth/verify-email')
}
로그인
// app/auth/login/actions.ts
'use server'
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
export async function signIn(formData: FormData) {
const supabase = await createClient()
const { error } = await supabase.auth.signInWithPassword({
email: formData.get('email') as string,
password: formData.get('password') as string,
})
if (error) {
return { error: error.message }
}
redirect('/dashboard')
}
로그아웃
// app/auth/logout/actions.ts
'use server'
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
export async function signOut() {
const supabase = await createClient()
await supabase.auth.signOut()
redirect('/login')
}
비밀번호 재설정
// 비밀번호 재설정 메일 발송
export async function resetPassword(formData: FormData) {
const supabase = await createClient()
const { error } = await supabase.auth.resetPasswordForEmail(
formData.get('email') as string,
{
redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/update-password`,
}
)
if (error) return { error: error.message }
return { success: true }
}
// 새 비밀번호 설정 (콜백 후)
export async function updatePassword(formData: FormData) {
const supabase = await createClient()
const { error } = await supabase.auth.updateUser({
password: formData.get('password') as string,
})
if (error) return { error: error.message }
redirect('/dashboard')
}
Magic Link (이메일 OTP)
비밀번호 없이 이메일 링크로 로그인하는 방식입니다. 사용자 경험이 뛰어나고, 비밀번호 분실 문제가 없습니다.
// Magic Link 발송
export async function signInWithMagicLink(formData: FormData) {
const supabase = await createClient()
const { error } = await supabase.auth.signInWithOtp({
email: formData.get('email') as string,
options: {
emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
// 이미 가입된 이메일만 허용하려면:
// shouldCreateUser: false,
},
})
if (error) return { error: error.message }
return { success: true }
}
이메일 OTP(6자리 코드)를 직접 입력받는 방식도 가능합니다:
// 이메일 OTP 코드 검증
export async function verifyOtp(formData: FormData) {
const supabase = await createClient()
const { error } = await supabase.auth.verifyOtp({
email: formData.get('email') as string,
token: formData.get('otp') as string,
type: 'email',
})
if (error) return { error: error.message }
redirect('/dashboard')
}
소셜 로그인 (OAuth)
Supabase는 Google, GitHub, Apple, Kakao, Facebook, Twitter/X 등 20여 개의 OAuth 제공자를 지원합니다.
대시보드 설정
Authentication → Providers에서 원하는 제공자를 활성화하고, 각 서비스에서 발급받은 Client ID와 Client Secret을 입력합니다.
Google OAuth 설정 절차:
- Google Cloud Console에서 OAuth 2.0 클라이언트 ID 생성
- 승인된 리디렉션 URI에
https://[project-ref].supabase.co/auth/v1/callback추가 - Supabase 대시보드에 Client ID, Client Secret 입력
소셜 로그인 구현
// app/auth/login/page.tsx (Client Component)
'use client'
import { createClient } from '@/lib/supabase/client'
export default function LoginPage() {
const supabase = createClient()
const signInWithGoogle = async () => {
await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${window.location.origin}/auth/callback`,
// 추가 스코프 요청
scopes: 'openid email profile',
},
})
}
const signInWithGithub = async () => {
await supabase.auth.signInWithOAuth({
provider: 'github',
options: {
redirectTo: `${window.location.origin}/auth/callback`,
},
})
}
const signInWithKakao = async () => {
await supabase.auth.signInWithOAuth({
provider: 'kakao',
options: {
redirectTo: `${window.location.origin}/auth/callback`,
},
})
}
return (
<div>
<button onClick={signInWithGoogle}>Google로 로그인</button>
<button onClick={signInWithGithub}>GitHub으로 로그인</button>
<button onClick={signInWithKakao}>카카오로 로그인</button>
</div>
)
}
소셜 로그인 후 프로필 저장
소셜 로그인 시 user_metadata에 제공자의 프로필 정보가 담깁니다. 이를 별도 profiles 테이블에 동기화하는 패턴이 일반적입니다.
-- profiles 테이블
CREATE TABLE profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
full_name TEXT,
avatar_url TEXT,
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- 회원가입 시 자동으로 profiles 레코드 생성하는 트리거
CREATE OR REPLACE FUNCTION handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.profiles (id, full_name, avatar_url)
VALUES (
NEW.id,
NEW.raw_user_meta_data->>'full_name',
NEW.raw_user_meta_data->>'avatar_url'
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION handle_new_user();
전화번호 / SMS OTP 로그인
전화번호 기반 인증을 활성화하려면 Twilio, MessageBird 등 SMS 제공자를 연결해야 합니다.
// 전화번호로 OTP 발송
export async function signInWithPhone(formData: FormData) {
const supabase = await createClient()
const { error } = await supabase.auth.signInWithOtp({
phone: formData.get('phone') as string, // 예: '+821012345678'
})
if (error) return { error: error.message }
return { success: true }
}
// SMS OTP 코드 검증
export async function verifyPhoneOtp(formData: FormData) {
const supabase = await createClient()
const { error } = await supabase.auth.verifyOtp({
phone: formData.get('phone') as string,
token: formData.get('otp') as string,
type: 'sms',
})
if (error) return { error: error.message }
redirect('/dashboard')
}
다중 인증 (MFA)
MFA는 1차 로그인(이메일/소셜) 이후 추가 인증 단계를 요구합니다. Supabase는 TOTP(Google Authenticator, Authy 등)와 SMS 방식을 지원합니다.
Authenticator Assurance Level (AAL)
MFA 상태는 JWT의 aal 클레임으로 표현됩니다.
aal1: 1차 인증만 완료 (이메일/소셜 로그인)aal2: 2차 인증까지 완료 (MFA 검증)
TOTP MFA 등록 흐름
// app/settings/mfa/EnrollMFA.tsx (Client Component)
'use client'
import { useState, useEffect } from 'react'
import { createClient } from '@/lib/supabase/client'
export default function EnrollMFA() {
const [qrCode, setQrCode] = useState<string>('')
const [factorId, setFactorId] = useState<string>('')
const [verifyCode, setVerifyCode] = useState<string>('')
const supabase = createClient()
useEffect(() => {
const enroll = async () => {
const { data, error } = await supabase.auth.mfa.enroll({
factorType: 'totp',
friendlyName: 'Authenticator App',
})
if (error) return console.error(error)
setFactorId(data.id)
// SVG QR코드를 data URL로 변환
setQrCode(`data:image/svg+xml;utf8,${encodeURIComponent(data.totp.qr_code)}`)
}
enroll()
}, [])
const verify = async () => {
// 1단계: 챌린지 생성
const { data: challenge, error: challengeError } =
await supabase.auth.mfa.challenge({ factorId })
if (challengeError) return console.error(challengeError)
// 2단계: 코드 검증
const { error: verifyError } = await supabase.auth.mfa.verify({
factorId,
challengeId: challenge.id,
code: verifyCode,
})
if (verifyError) return console.error(verifyError)
alert('MFA 등록 완료!')
}
return (
<div>
<p>아래 QR코드를 인증 앱으로 스캔하세요.</p>
{qrCode && <img src={qrCode} alt="MFA QR Code" />}
<input
type="text"
placeholder="6자리 코드 입력"
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value)}
/>
<button onClick={verify}>인증 완료</button>
</div>
)
}
MFA 로그인 챌린지 (middleware에서 강제)
// middleware.ts — AAL 레벨 확인 추가
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: () => request.cookies.getAll(),
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
)
supabaseResponse = NextResponse.next({ request })
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
}
)
const { data: { user } } = await supabase.auth.getUser()
// 보호된 라우트 처리
if (!user && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url))
}
// MFA 필수 라우트 처리
if (user && request.nextUrl.pathname.startsWith('/dashboard')) {
const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
if (aal?.nextLevel === 'aal2' && aal.currentLevel !== 'aal2') {
return NextResponse.redirect(new URL('/auth/mfa-challenge', request.url))
}
}
return supabaseResponse
}
라우트 보호 패턴
Server Component에서 세션 확인
// app/dashboard/page.tsx
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
export default async function DashboardPage() {
const supabase = await createClient()
// getUser()는 JWT를 서버에서 검증 (getSession()보다 안전)
const { data: { user }, error } = await supabase.auth.getUser()
if (!user || error) {
redirect('/login')
}
return (
<div>
<h1>안녕하세요, {user.email}님!</h1>
</div>
)
}
⚠️ 중요:
getSession()대신getUser()를 사용하세요.getSession()은 쿠키의 JWT를 그대로 신뢰하지만,getUser()는 Supabase 서버에 토큰 유효성을 검증합니다.
Client Component에서 세션 구독
// components/AuthProvider.tsx
'use client'
import { createContext, useContext, useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import type { User } from '@supabase/supabase-js'
const AuthContext = createContext<{ user: User | null }>({ user: null })
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const supabase = createClient()
useEffect(() => {
// 현재 세션 가져오기
supabase.auth.getUser().then(({ data: { user } }) => setUser(user))
// 인증 상태 변화 구독
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_event, session) => {
setUser(session?.user ?? null)
}
)
return () => subscription.unsubscribe()
}, [])
return (
<AuthContext.Provider value={{ user }}>
{children}
</AuthContext.Provider>
)
}
export const useAuth = () => useContext(AuthContext)
SAML SSO (기업용)
SAML SSO는 기업 고객이 자사의 Identity Provider(Okta, Azure AD, Google Workspace 등)로 로그인할 수 있게 해줍니다. Team 플랜 이상에서 사용 가능합니다.
SAML 연동 설정 (대시보드)
- Authentication → SSO → Add provider
- Identity Provider의 메타데이터 URL 또는 XML 입력
- Supabase가 제공하는 SP Entity ID, ACS URL을 IdP에 등록
SAML 로그인 코드
// SSO 도메인 기반 자동 라우팅
const signInWithSSO = async (email: string) => {
const supabase = createClient()
const domain = email.split('@')[1]
const { data, error } = await supabase.auth.signInWithSSO({
domain, // 이메일 도메인으로 올바른 IdP를 자동 선택
options: {
redirectTo: `${window.location.origin}/auth/callback`,
},
})
if (data?.url) {
window.location.href = data.url // IdP 로그인 페이지로 리다이렉트
}
}
2025년 신기능: OAuth 2.1 Identity Provider
Supabase Auth가 이제 직접 OAuth 2.1 서버가 될 수 있습니다. “Sign in with Google”처럼 “Sign in with [Your App]”을 구축할 수 있습니다.
주요 활용 사례:
- 자사 플랫폼에서 써드파티 앱 연동 허용
- MCP 서버 인증 (AI 에이전트가 사용자 데이터에 접근 시 인증)
- 엔터프라이즈 OIDC 페더레이션
// OAuth 2.1 서버 설정 후, 사용자 동의 화면 구현
// app/oauth/authorize/page.tsx
import { createClient } from '@/lib/supabase/server'
export default async function AuthorizePage({
searchParams,
}: {
searchParams: { request_id: string }
}) {
const supabase = await createClient()
// 요청 정보 조회 (앱 이름, 요청 권한 등)
const { data: authRequest } = await supabase.auth.oauth.getAuthorizationRequest({
requestId: searchParams.request_id,
})
return (
<div>
<h1>{authRequest?.client_name}이(가) 접근을 요청합니다</h1>
<p>다음 권한을 허용하시겠습니까?</p>
{/* 승인/거부 버튼 */}
</div>
)
}
실무에서 자주 하는 실수
1. getSession() vs getUser() 혼동
// ❌ 위험 — 서버에서 JWT 검증 없이 쿠키만 신뢰
const { data: { session } } = await supabase.auth.getSession()
const user = session?.user
// ✅ 안전 — Supabase 서버에서 JWT 유효성 검증
const { data: { user } } = await supabase.auth.getUser()
2. 미들웨어에서 세션 갱신 누락
미들웨어에서 await supabase.auth.getUser()를 호출하지 않으면 세션이 자동 갱신되지 않아 만료된 세션 문제가 발생합니다.
3. 이메일 인증 없이 서비스 출시
Supabase 대시보드 → Authentication → Email Templates에서 이메일 인증 활성화와 커스텀 이메일 템플릿을 반드시 설정하세요. 기본 이메일은 Supabase 브랜딩이 노출됩니다.
4. user_metadata와 app_metadata 혼동
user_metadata: 사용자가 직접 수정 가능 (이름, 프로필 사진 등)app_metadata: 관리자만 수정 가능 (역할, 권한 등)
// 역할 부여는 반드시 service_role 키로 (Server Action)
const supabaseAdmin = createClient(url, serviceRoleKey)
await supabaseAdmin.auth.admin.updateUserById(userId, {
app_metadata: { role: 'admin' },
})
로그인 페이지 예시 (완성형)
// app/login/page.tsx
import { signIn, signInWithMagicLink } from './actions'
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
export default async function LoginPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
// 이미 로그인된 경우 대시보드로
if (user) redirect('/dashboard')
return (
<div className="max-w-md mx-auto mt-20 p-6">
<h1 className="text-2xl font-bold mb-6">로그인</h1>
{/* 이메일/비밀번호 로그인 */}
<form action={signIn} className="space-y-4 mb-6">
<input name="email" type="email" placeholder="이메일" required className="w-full border p-2 rounded" />
<input name="password" type="password" placeholder="비밀번호" required className="w-full border p-2 rounded" />
<button type="submit" className="w-full bg-black text-white p-2 rounded">
로그인
</button>
</form>
<div className="text-center text-gray-500 mb-4">또는</div>
{/* Magic Link */}
<form action={signInWithMagicLink} className="space-y-4 mb-6">
<input name="email" type="email" placeholder="이메일" required className="w-full border p-2 rounded" />
<button type="submit" className="w-full border p-2 rounded">
이메일 링크로 로그인
</button>
</form>
{/* 소셜 로그인은 Client Component에서 처리 */}
<SocialLoginButtons />
</div>
)
}
마치며
Supabase Auth는 단순한 이메일 로그인부터 기업용 SAML SSO, MFA, 그리고 OAuth 2.1 Identity Provider까지 폭넓은 인증 요구사항을 커버합니다. 특히 JWT가 RLS 정책과 자동으로 연동되는 구조는 Supabase Auth의 가장 강력한 장점입니다.
다음 편에서는 바로 이 연동의 핵심인 Row Level Security(RLS) 를 심도 있게 다룹니다. “어떤 사용자가 어떤 데이터에 접근할 수 있는가”를 DB 레벨에서 정의하는 RLS는, Supabase를 안전하게 쓰기 위한 필수 지식입니다.