Supabase Auth — 이메일·OAuth·매직 링크를 SDK 한 벌로
GoTrue 기반 인증 서버의 동작 방식과 가입·로그인·세션·SSR 처리를 실전 예제로 정리합니다.
들어가며
직접 만들면 안 되는 것이 몇 가지 있다 — 암호 알고리즘, 결제 시스템, 그리고 인증. 인증은 겉보기엔 단순하다. 이메일을 받고, 비밀번호를 해시하고, 토큰을 발급한다. 그러나 제대로 만들려면 비밀번호 정책, 이메일 검증, 비밀번호 재설정, 토큰 만료·갱신, 디바이스 관리, OAuth 콜백, 무상태 SSR, MFA, brute-force 보호… 끝이 없다.
Supabase 는 이걸 GoTrue 라는 오픈소스 인증 서버에 위탁한다. GoTrue 는 Netlify 가 만들고 Supabase 가 fork·발전시킨 작고 단단한 인증 서버다. 우리는 SDK 한 벌과 정책 몇 줄로 그 결과만 받아 쓴다.
GoTrue 가 하는 일
내부적으로 GoTrue 가 하는 일은 다음 정도로 요약된다.
- 사용자 회원가입·로그인·로그아웃을 처리한다.
- 비밀번호를 bcrypt 로 해시해
auth.users테이블에 보관한다. - 인증이 성공하면 짧은 만료의
access_token(JWT) 과 긴 만료의refresh_token을 발급한다. - access token 만료가 가까워지면 refresh token 으로 새 토큰을 자동 발급한다.
- OAuth provider(Google, GitHub, ...) 와의 콜백 흐름을 대신 한다.
- 비밀번호 재설정·이메일 검증·매직 링크 발송을 SMTP 또는 외부 서비스로 처리한다.
발급된 JWT 는 PostgREST 가 받아 request.jwt.claims 로 Postgres 에 흘려주고, 이전 편에서 본 RLS 가 그 값을 읽어 권한을 결정한다.
이메일 + 패스워드 — 가장 단순한 흐름
먼저 가장 익숙한 형태부터.
가입
import { createClient } from '@supabase/supabase-js';const supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,);const { data, error } = await supabase.auth.signUp({ email: 'kim@example.com', password: 'super-secret-passphrase', options: { emailRedirectTo: 'https://example.com/auth/callback', },});기본 설정에서는 가입 직후 GoTrue 가 확인 메일 을 보낸다. 사용자가 메일 링크를 누르기 전엔 auth.users.email_confirmed_at 이 null 이고, 그 상태로는 로그인 시도를 거부한다.
로그인
const { data, error } = await supabase.auth.signInWithPassword({ email: 'kim@example.com', password: 'super-secret-passphrase',});if (error) { // Invalid login credentials, Email not confirmed, ... console.error(error.message);}console.log(data.session); // access_token, refresh_token, expires_at, user, ...성공하면 SDK 가 session 을 SDK 내부 storage(브라우저면 localStorage 기본) 에 저장한다. 이후의 모든 supabase.from(...) 호출은 그 토큰을 자동으로 헤더에 붙여 보낸다.
매직 링크 — 비밀번호 없는 인증
이메일만 받고 그 이메일로 원클릭 로그인 링크 를 보낸다. 보안이 단순하고 사용자 마찰이 적어 사이드 프로젝트나 B2B 도구에서 인기.
const { error } = await supabase.auth.signInWithOtp({ email: 'kim@example.com', options: { emailRedirectTo: 'https://example.com/auth/callback', },});사용자가 메일의 링크를 누르면 emailRedirectTo 로 돌아오면서 URL 해시(또는 코드 파라미터)에 토큰이 실려 온다. SDK 가 그 토큰을 읽어 세션을 만든다.
OAuth — 소셜 로그인
Google·GitHub·Apple·Discord·Kakao 등 주요 provider 가 클릭 한 번으로 연결된다. 대시보드 Authentication → Providers 에서 클라이언트 ID·시크릿을 입력하면 끝.
const { data, error } = await supabase.auth.signInWithOAuth({ provider: 'github', options: { redirectTo: 'https://example.com/auth/callback', scopes: 'read:user user:email', },});호출하면 SDK 가 브라우저를 GitHub 로 보내고, GitHub 가 인증 후 우리 콜백 URL 로 돌려보낸다. 콜백에서는 SDK 가 자동으로 토큰을 교환해 세션을 만든다.
OAuth 콜백 라우트는 보통 별도로 한 줄 만들어 둔다.
import { NextResponse } from 'next/server';import { createServerClient } from '@/lib/supabase/server';export async function GET(request: Request) { const url = new URL(request.url); const code = url.searchParams.get('code'); if (code) { const supabase = createServerClient(); await supabase.auth.exchangeCodeForSession(code); } return NextResponse.redirect(new URL('/', request.url));}세션과 JWT — RLS 의 입력
세션 객체는 대략 이렇게 생겼다.
interface Session { access_token: string; // 짧게(기본 1시간) 살아 있는 JWT refresh_token: string; // 길게 살아 있는 갱신 토큰 expires_at: number; // unix timestamp user: User;}SDK 는 만료가 가까워지면 자동으로 refresh_token 으로 새 access token 을 받아온다. 우리가 명시적으로 갱신 코드를 짤 필요는 없다.
JWT 의 payload(=auth.jwt() 가 보는 값) 안에는 다음이 들어 있다.
sub— user UUID. RLS 의auth.uid()가 이걸 꺼낸다.email,phone— 식별자.role—authenticated또는anon.aal— 인증 보증 레벨 (Authenticator Assurance Level). MFA 통과 여부.app_metadata— 시스템이 박는 메타. 사용자가 못 바꿈.user_metadata— 사용자가 자기 프로필에서 채워 넣는 메타.
SSR (Next.js) — 서버에서 토큰 다루기
브라우저 SDK 는 localStorage 를 기본 storage 로 쓴다. 이 모델은 SSR(서버 사이드 렌더링) 과 잘 안 맞는다 — 서버에서는 localStorage 가 없고, 첫 요청에서 사용자가 누구인지를 알아야 한다.
해결책: 쿠키 기반 storage 를 쓰는 SSR 헬퍼를 SDK 가 따로 제공한다.
import { createServerClient } from '@supabase/ssr';import { cookies } from 'next/headers';export function createClient() { const cookieStore = cookies(); return createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { get: (name) => cookieStore.get(name)?.value, set: (name, value, options) => cookieStore.set({ name, value, ...options }), remove: (name, options) => cookieStore.set({ name, value: '', ...options }), }, }, );}서버 컴포넌트·라우트 핸들러·미들웨어에서 이 클라이언트를 쓰면, 쿠키에 저장된 토큰이 자동으로 PostgREST 호출 헤더에 실린다 — RLS 가 사용자 컨텍스트로 동작한다.
import { createClient } from '@/lib/supabase/server';export default async function Home() { const supabase = createClient(); const { data: posts } = await supabase .from('posts') .select('id, title') .order('created_at', { ascending: false }); return <ul>{posts?.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;}서버 사이드라도 해당 사용자 토큰 으로 호출되므로, RLS 가 그대로 적용된다. 익명 사용자는 익명 권한으로, 로그인 사용자는 로그인 권한으로 — 한 코드.
MFA — 간단히
Supabase Auth 는 TOTP 기반 MFA 를 지원한다 (Authenticator 앱). 큰 그림:
// 1) 사용자가 등록할 때 — TOTP secret 발급const { data: enroll } = await supabase.auth.mfa.enroll({ factorType: 'totp' });// data.totp.qr_code 를 화면에 띄움 — 사용자가 스캔// 2) 첫 코드를 입력해 등록 검증await supabase.auth.mfa.challengeAndVerify({ factorId: enroll.id, code: '123456',});// 3) 다음 로그인 시 패스워드 통과 후, MFA 단계const { data: factors } = await supabase.auth.mfa.listFactors();await supabase.auth.mfa.challengeAndVerify({ factorId: factors.totp[0].id, code: '654321',});MFA 통과 여부는 JWT 의 aal 에 박힌다(aal1 = 패스워드만, aal2 = MFA 통과). RLS 정책에서 민감 작업은 aal2 만 허용 같은 정책이 가능.
create policy "delete requires mfa"on posts for deleteusing ( auth.uid() = author_id and (auth.jwt() ->> 'aal') = 'aal2');정리
Supabase Auth 의 좋은 점은 JWT 가 RLS 의 직접 입력이 된다 는 것이다. 인증과 권한이 같은 토큰 위에서 한 줄로 연결된다 — 이게 1편에서 말한 "권한을 데이터 옆에 두는 모델" 의 토대다.
세 가지만 남는다.
- 인증 방식은 이메일·매직링크·OAuth 중 도메인에 맞게 섞어 쓴다.
- SSR 환경에서는 쿠키 storage 헬퍼 로 토큰을 다룬다 — localStorage 아님.
- 권한 분기는 절대
user_metadata가 아닌app_metadata또는 DB.