테마 전환

Supabase Auth 심층 설정: OAuth, SSO 및 권한 제어

Easton editorial illustration: solo-founder business system console

어느 날 오후, 영업 담당자가 달려와 말했습니다. “고객이 Okta 로그인을 요구하는데, 2주 안에 SSO를 출시해야 해요.” 순간 당황했습니다. 제 애플리케이션은 이메일 가입과 Google OAuth만 지원했고 SAML은 한 번도 다뤄 본 적이 없었기 때문입니다.

이런 상황은 B2B SaaS에서 매우 흔합니다. 기업 고객은 직원들이 서비스마다 별도의 계정을 만들게 두지 않습니다. Okta, Azure AD, Google Workspace 같은 통합 ID 관리 시스템을 사용하고, 한 번에 로그인 화면으로 이동하며, 직원이 퇴사하면 계정이 자동으로 비활성화되기를 원합니다.

이 글은 바로 이런 상황을 위해 준비했습니다. OAuth 소셜 로그인에서 시작해 SAML SSO 기업 연동으로 확장하고, 마지막에는 Row Level Security(RLS)로 멀티테넌트 권한 격리를 구현합니다. 소비자용부터 기업용까지 아우르는 완전한 인증 및 권한 부여 솔루션입니다.

1. OAuth 다중 Provider 설정 실전

OAuth 소셜 로그인은 대부분의 애플리케이션에서 출발점입니다. 사용자는 비밀번호를 외우고 싶지 않고, 개발자도 비밀번호 저장과 검증을 직접 처리하고 싶지 않습니다. Google이나 GitHub에 맡기면 양쪽 모두 편해집니다.

Supabase는 Google, GitHub, Apple, Facebook, Discord, Twitter 등 다양한 OAuth Provider를 지원합니다. 실제 프로덕션 환경에서는 Google과 GitHub가 가장 널리 쓰이고, Apple은 iOS 앱의 필수 요건입니다(App Store 심사에 필요). 먼저 Google부터 살펴보겠습니다.

Google OAuth 설정

Google Cloud Console을 열면 빽빽한 메뉴 때문에 막막할 때가 있습니다. 당황하지 말고 “OAuth Client ID”를 검색하면 바로 진입점을 찾을 수 있습니다.

생성할 때 Web application 유형을 선택하고, 가장 중요한 Authorized redirect URI를 입력합니다.

https://<프로젝트ref>.supabase.co/auth/v1/callback

이 주소는 Supabase Auth 서비스가 OAuth 콜백을 받는 곳입니다. 프로젝트 ref는 Supabase Dashboard 왼쪽 위에서 확인할 수 있으며, abcdefghijklmnop 같은 형태입니다.

Client ID와 Client Secret을 발급받은 뒤 Supabase Dashboard로 돌아가 Authentication > Providers에서 Google을 활성화하고 두 값을 입력합니다.

호출 코드는 간단합니다.

import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  'https://abcdefghijklmnop.supabase.co',
  'your-anon-key'
)

// OAuth 로그인 시작
const { data, error } = await supabase.auth.signInWithOAuth({
  provider: 'google',
  options: {
    redirectTo: 'https://your-app.com/auth/callback',
    scopes: 'email profile'
  }
})

if (error) {
  console.error('로그인 실패:', error.message)
  return
}

// data.url은 이동할 주소이므로 window.location.href = data.url로 설정하면 됩니다.

redirectTo는 애플리케이션이 로그인 결과를 받는 주소입니다. Supabase가 code와 state 매개변수를 전달합니다. 해당 페이지에서 supabase.auth.exchangeCodeForSession()을 호출해 로그인을 완료해야 합니다.

// /auth/callback 페이지에서
const { error } = await supabase.auth.exchangeCodeForSession()
if (!error) {
  // 로그인 성공 후 홈으로 이동
  window.location.href = '/'
}

GitHub OAuth 설정

GitHub 설정도 비슷하며, Settings > Developer settings > OAuth Apps에서 시작합니다. 생성할 때 입력하는 Authorization callback URL 역시 https://<프로젝트ref>.supabase.co/auth/v1/callback입니다.

한 가지 차이가 있습니다. GitHub OAuth App에는 scopes 설정 화면이 없고 코드에서 scopes를 지정합니다.

await supabase.auth.signInWithOAuth({
  provider: 'github',
  options: {
    redirectTo: 'https://your-app.com/auth/callback',
    scopes: 'repo user'  // repo를 지정하면 비공개 저장소에 접근할 수 있습니다.
  }
})

사용자 로그인만 필요하다면 기본 scopes로 충분합니다. 저장소 목록 동기화처럼 사용자의 GitHub 데이터에 접근해야 한다면 scopes에 repo를 추가해야 합니다.

Apple OAuth 설정

Apple 설정이 가장 까다롭습니다. Apple Developer Portal에서 Services ID를 생성하고 비공개 키(.p8 파일)도 만들어야 합니다. 비공개 키는 한 번만 다운로드할 수 있으므로 잃어버리면 다시 생성해야 합니다.

주요 매개변수는 다음과 같습니다.

  • Services ID: Client ID와 유사
  • Team ID: Membership 페이지에서 확인
  • Key ID: 비공개 키의 ID
  • Private Key: 다운로드한 .p8 파일 내용

Supabase Dashboard에 값을 입력할 때 Private Key에는 BEGIN/END 두 줄을 포함한 파일 전체 내용을 복사해야 합니다.

호출 코드는 Google/GitHub와 같습니다.

await supabase.auth.signInWithOAuth({
  provider: 'apple',
  options: {
    redirectTo: 'https://your-app.com/auth/callback'
  }
})

iOS 앱에서는 네이티브 Sign in with Apple API를 직접 호출하는 방법도 있습니다. identity token을 받은 뒤 Supabase에 전달합니다.

// 프런트엔드가 Apple에서 반환한 identity_token을 받은 뒤
const { data, error } = await supabase.auth.signInWithIdTokenCredentials({
  provider: 'apple',
  token: identityToken
})

이미 네이티브 Apple 로그인을 연동한 iOS 앱이라면 기존 로직을 재사용할 수 있어 이 방식이 적합합니다.

2. SAML SSO 기업 연동

처음의 상황으로 돌아가 보겠습니다. 고객이 Okta 로그인을 요구한다면 OAuth만으로는 부족하고 SAML 2.0 SSO가 필요합니다.

SAML의 동작 방식은 OAuth와 완전히 다릅니다. OAuth에서는 사용자가 자신의 데이터에 접근할 권한을 서드파티 애플리케이션에 부여합니다. 반면 SAML에서는 기업 ID 공급자(IdP)가 애플리케이션(Service Provider, SP)에 사용자 신원 정보를 보냅니다. 기업 입장에서는 SAML을 더 강력하게 제어할 수 있습니다. 직원이 퇴사하면 IdP에서 계정을 비활성화하는 즉시 연결된 모든 애플리케이션에서도 접근할 수 없게 됩니다.

설정 전 준비

고객에게서 IdP Metadata를 받아야 합니다. 이 파일에는 IdP의 인증서와 엔드포인트 주소 등이 들어 있습니다. Okta, Azure AD, Google Workspace 모두 Metadata를 내보내는 기능을 제공합니다.

Supabase가 제공하는 주요 URL은 다음과 같습니다.

EntityID(SP 식별자):
https://&lt;프로젝트ref&gt;.supabase.co/auth/v1/sso/saml/metadata

ACS URL(SAML Response 수신 주소):
https://&lt;프로젝트ref&gt;.supabase.co/auth/v1/sso/saml/acs

Metadata URL(다운로드 가능):
https://&lt;프로젝트ref&gt;.supabase.co/auth/v1/sso/saml/metadata?download=true

이 URL들을 고객사의 IT 관리자에게 전달하고 Okta/Azure AD에서 SAML 애플리케이션을 만들도록 요청합니다.

Supabase CLI로 SSO 설정

현재 Supabase Dashboard에서도 SAML을 설정할 수 있지만 저는 CLI가 더 익숙합니다. 기업 고객의 설정은 반복해서 디버깅해야 할 수 있으므로 명령줄이 더 통제하기 쉽습니다.

# SAML 연결 추가
supabase sso add --type saml --project-ref &lt;프로젝트ref&gt; \
  --metadata-url 'https://company.okta.com/app/exk123/saml/samlmetadata' \
  --domains company.com

—domains 매개변수는 매우 중요합니다. company.com 도메인의 모든 사용자가 로그인할 때 이 SAML 연결을 사용해야 한다고 Supabase에 알려 줍니다.

—metadata-url 대신 —metadata-file을 사용해 XML 파일을 직접 업로드할 수도 있습니다.

supabase sso add --type saml --project-ref &lt;프로젝트ref&gt; \
  --metadata-file ./okta-metadata.xml \
  --domains company.com

명령을 실행하면 abc123def456 같은 sso_provider_id가 반환됩니다. 이 ID는 RLS 권한 격리에서 핵심 역할을 합니다.

Attribute Mapping 설정

SAML Response에는 이메일, 이름, 부서 등의 사용자 정보가 포함되지만 필드 이름은 제각각일 수 있습니다. Supabase에 이 필드들을 매핑하는 방법을 알려 줘야 합니다.

JSON 파일을 만듭니다.

{
  "keys": {
    "email": {
      "name": "email",
      "names": ["EmailAddress", "email", "mail"],
      "required": true
    },
    "first_name": {
      "name": "first_name",
      "names": ["FirstName", "givenName", "first_name"]
    },
    "last_name": {
      "name": "last_name",
      "names": ["LastName", "surname", "last_name"]
    }
  }
}

그런 다음 supabase sso update로 이 설정을 적용합니다.

supabase sso update &lt;sso_provider_id&gt; \
  --project-ref &lt;프로젝트ref&gt; \
  --attribute-mapping-file ./mapping.json

멀티테넌트 SSO 설정

하나의 프로젝트에 여러 SAML 연결을 추가할 수 있습니다. 예를 들어 기업 고객이 두 곳이고 Acme Corp은 Okta, Globex Inc는 Azure AD를 사용한다고 가정해 보겠습니다.

# Acme Corp의 SSO 추가
supabase sso add --type saml --project-ref &lt;프로젝트ref&gt; \
  --metadata-url 'https://acme.okta.com/.../metadata' \
  --domains acme.com

# 반환값 sso_provider_id: provider_abc

# Globex Inc의 SSO 추가
supabase sso add --type saml --project-ref &lt;프로젝트ref&gt; \
  --metadata-url 'https://globex.azure.com/.../metadata' \
  --domains globex.com

# 반환값 sso_provider_id: provider_def

이제 Acme Corp 직원은 로그인할 때 provider_abc를 사용하고 Globex Inc 직원은 provider_def를 사용합니다. 각 SSO 연결의 사용자 데이터는 서로 격리됩니다.

사용자 로그인 경험

설정을 마치면 사용자의 로그인 흐름은 다음과 같습니다.

  1. 사용자가 이메일을 입력합니다(예: [email protected]).
  2. Supabase가 acme.com 도메인에 SSO 설정이 있음을 감지합니다.
  3. Acme의 Okta 로그인 페이지로 자동 이동합니다.
  4. 사용자가 Okta에서 계정과 비밀번호를 입력합니다. 이미 로그인했다면 바로 통과할 수 있습니다.
  5. Okta가 SAML Response를 Supabase로 보냅니다.
  6. Supabase가 검증 후 session을 만들고 애플리케이션으로 돌려보냅니다.

코드에서 SSO 로그인을 시작합니다.

// 사용자가 이메일을 입력한 뒤 이 메서드를 호출합니다.
const { data, error } = await supabase.auth.signInWithSSO({
  domain: 'acme.com'
})

if (data?.url) {
  // SSO 로그인 페이지로 이동
  window.location.href = data.url
}

sso_provider_id를 직접 사용할 수도 있습니다.

const { data, error } = await supabase.auth.signInWithSSO({
  providerId: 'provider_abc'
})

SSO 사용자의 특징

SSO 로그인으로 생성된 사용자는 일반 사용자와 다릅니다.

  • 이메일은 IdP가 관리: 사용자가 애플리케이션에서 이메일을 변경할 수 없습니다.
  • 비밀번호 없음: 비밀번호는 IdP에 저장되며 Supabase는 검증에 관여하지 않습니다.
  • 이메일 검증 불필요: IdP가 이미 검증했기 때문입니다.

따라서 사용자가 이메일을 변경해야 한다면 고객사의 IT 관리자가 Okta/Azure AD에서 처리해야 합니다.

3. Row Level Security 고급 활용

인증은 “사용자가 누구인가”라는 문제를 해결하고, 권한 부여는 “사용자가 무엇을 할 수 있는가”라는 문제를 해결합니다.

전통적인 방식은 비즈니스 코드에서 권한을 확인하는 것입니다. 모든 API에서 현재 사용자가 해당 데이터에 접근할 수 있는지 판단해야 합니다. 코드가 반복되고 누락되기 쉬우며 성능도 좋지 않습니다. 요청마다 데이터베이스를 조회해 권한을 판단해야 하기 때문입니다.

PostgreSQL의 Row Level Security(RLS)는 권한 검사를 데이터베이스 계층으로 내립니다. 모든 쿼리에 권한 필터가 자동으로 적용되므로 비즈니스 코드가 따로 신경 쓸 필요가 없습니다.

RLS 활성화 후의 기본 동작

많은 사람이 여기서 실수합니다. RLS를 활성화하면 기본적으로 모든 접근을 거부합니다.

-- RLS 활성화
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;

-- 이제 모든 쿼리가 빈 결과를 반환합니다(관리자 포함).
SELECT * FROM posts;  -- 0개 행 반환

접근을 허용하려면 반드시 Policy를 생성해야 합니다.

Policy의 두 가지 핵심 요소

Policy에는 USING과 WITH CHECK라는 두 clause가 있습니다.

USING clause: 사용자가 해당 데이터를 “보거나” “찾을” 수 있는지 판단합니다. SELECT, UPDATE, DELETE 작업에 사용됩니다. 데이터를 먼저 볼 수 있어야 수정하거나 삭제할 수 있습니다.

WITH CHECK clause: 사용자가 해당 데이터를 “쓸” 수 있는지 판단합니다. INSERT, UPDATE 작업에 사용됩니다. 쓰기가 끝난 데이터가 조건을 충족해야 합니다.

-- 사용자는 자신의 posts만 조작할 수 있습니다.
CREATE POLICY "Users manage own posts" ON posts
FOR ALL TO authenticated
USING (auth.uid() = author_id)
WITH CHECK (auth.uid() = author_id);

-- 조회만 허용하고 수정은 허용하지 않습니다.
CREATE POLICY "Users view own posts" ON posts
FOR SELECT TO authenticated
USING (auth.uid() = author_id);

-- 삽입만 허용하고 다른 사용자의 데이터 조회는 허용하지 않습니다.
CREATE POLICY "Users insert posts" ON posts
FOR INSERT TO authenticated
WITH CHECK (auth.uid() = author_id);

auth.uid()는 현재 로그인한 사용자의 UUID를 반환합니다. 로그인하지 않은 경우에는 null을 반환하므로 TO authenticated를 지정하면 Policy가 로그인 사용자에게만 적용됩니다.

RESTRICTIVE와 PERMISSIVE

하나의 테이블에 여러 Policy를 둘 수 있습니다. 기본값은 PERMISSIVE 모드로, 어느 하나의 Policy만 충족해도 접근할 수 있습니다.

-- Policy 1: 사용자가 자신의 데이터를 볼 수 있습니다.
CREATE POLICY "Own data" ON posts
FOR SELECT USING (auth.uid() = author_id);

-- Policy 2: 모든 사용자가 공개 게시물을 볼 수 있습니다.
CREATE POLICY "Public posts" ON posts
FOR SELECT USING (is_public = true);

-- 두 PERMISSIVE Policy 중 하나만 충족하면 됩니다.

반면 RESTRICTIVE Policy는 반드시 충족해야 합니다. PERMISSIVE Policy와 함께 사용해 더 엄격하게 제한합니다.

-- 모든 접근이 이 조건을 충족해야 합니다("전역 필터" 역할).
CREATE POLICY "Tenant isolation" ON posts
AS RESTRICTIVE TO authenticated
USING (tenant_id = (
  SELECT tenant_id FROM users WHERE id = auth.uid()
));

-- PERMISSIVE Policy를 추가해 구체적인 작업을 제어합니다.
CREATE POLICY "Authors edit own posts" ON posts
FOR UPDATE USING (auth.uid() = author_id);

이 조합에서는 사용자가 올바른 테넌트에 속해야 하고(RESTRICTIVE), 동시에 작성자여야 편집할 수 있습니다(PERMISSIVE).

멀티테넌트 격리의 전체 패턴

각 기업 고객이 하나의 테넌트인 SaaS 애플리케이션을 가정해 보겠습니다. 사용자 테이블에는 tenant_id가 있고, 모든 비즈니스 테이블을 테넌트별로 격리해야 합니다.

-- 사용자 테이블
CREATE TABLE users (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL,
  email TEXT,
  sso_provider_id TEXT  -- SSO 사용자에게만 존재
);

-- 비즈니스 테이블
CREATE TABLE projects (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  name TEXT,
  created_by UUID REFERENCES users(id)
);

-- RLS 활성화
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

-- 전역 테넌트 격리(RESTRICTIVE)
CREATE POLICY "Tenant isolation" ON projects
AS RESTRICTIVE TO authenticated
USING (tenant_id = (
  SELECT tenant_id FROM users WHERE id = auth.uid()
));

-- 구체적인 작업 권한(PERMISSIVE)
CREATE POLICY "Tenant users can view" ON projects
FOR SELECT TO authenticated
USING (true);  -- 이미 RESTRICTIVE에서 필터링했으므로 여기서는 허용

CREATE POLICY "Project creators can edit" ON projects
FOR UPDATE TO authenticated
USING (created_by = auth.uid());

이렇게 설정하면 모든 쿼리에 테넌트 필터가 자동으로 적용됩니다. 비즈니스 코드에서 SELECT * FROM projects를 실행하더라도 데이터베이스는 현재 테넌트의 데이터만 반환합니다.

SSO 사용자의 멀티테넌트 격리

SSO로 로그인한 사용자는 sso_provider_id를 이용해 격리할 수 있습니다. JWT에는 로그인 방식을 저장한 필드가 있습니다.

-- SSO 사용자를 위한 RLS Policy
CREATE POLICY "SSO tenant isolation" ON organization_settings
AS RESTRICTIVE TO authenticated
USING (sso_provider_id = (
  SELECT auth.jwt()#>>'{amr,0,provider}'
));

auth.jwt()#>>‘{amr,0,provider}‘는 JWT에서 로그인 방식의 provider ID를 추출합니다. SSO로 로그인했다면 이 값이 sso_provider_id입니다.

성능 최적화 포인트

RLS Policy는 암묵적인 WHERE clause입니다. 모든 쿼리에 자동으로 추가되고 서브쿼리를 포함할 수도 있으므로 성능에 영향을 줍니다.

다음과 같이 최적화할 수 있습니다.

1. Policy에서 사용하는 필드에 인덱스 생성

CREATE INDEX idx_posts_author ON posts(author_id);
CREATE INDEX idx_projects_tenant ON projects(tenant_id);

2. Policy 안의 서브쿼리 제거

서브쿼리는 각 데이터 행마다 실행되므로 비용이 큽니다. 더 나은 방법은 JWT에 tenant_id를 저장하고 auth.jwt()로 바로 추출하는 것입니다.

-- 권장하지 않음: 서브쿼리
USING (tenant_id = (SELECT tenant_id FROM users WHERE id = auth.uid()))

-- 권장: JWT에 tenant_id 저장
USING (tenant_id = (auth.jwt()-&gt;&gt;'tenant_id')::uuid)

뒤에서 Custom Access Token Hook을 이용해 tenant_id를 JWT에 넣는 방법을 설명하겠습니다.

3. SECURITY DEFINER 함수로 복잡한 로직 감싸기

Policy의 복잡한 로직을 함수로 감싸고 SECURITY DEFINER로 설정하면 매번 반복 실행하지 않아도 됩니다.

CREATE FUNCTION current_tenant_id() RETURNS UUID
LANGUAGE SQL STABLE SECURITY DEFINER AS $$
  SELECT tenant_id FROM users WHERE id = auth.uid();
$$;

-- Policy에서 함수 호출
CREATE POLICY "Tenant isolation" ON projects
AS RESTRICTIVE USING (tenant_id = current_tenant_id());

STABLE은 같은 트랜잭션 안에서 함수의 반환값이 변하지 않는다는 뜻입니다. PostgreSQL은 이를 한 번만 실행하도록 최적화합니다.

4. Custom Claims와 RBAC 구현

JWT에는 기본적으로 사용자 ID, 이메일, 역할(authenticated/anon) 등 Supabase 내장 필드만 있습니다. 하지만 실제로는 사용자 역할(admin/moderator), 테넌트 ID, 권한 목록 같은 정보가 더 필요한 경우가 많습니다.

Supabase는 JWT를 발급하기 전에 내용을 수정할 수 있는 Custom Access Token Hook을 제공합니다.

Custom Claims가 필요한 이유

대표적인 사례는 다음과 같습니다.

  1. RBAC 권한 제어: JWT에 사용자 역할을 저장하고 RLS Policy가 역할에 따라 권한을 판단
  2. 멀티테넌트 격리: JWT에 tenant_id를 저장해 Policy 안의 서브쿼리 제거
  3. JWT 크기 축소: 기본 JWT에는 필드가 많으므로 SSR 환경의 전송 비용을 낮추기 위해 불필요한 필드 삭제

Supabase의 JWT에는 기본적으로 session_id, aal, amr 등 많은 필드가 들어 있으며 요청마다 함께 전송됩니다. SSR 페이지가 많은 애플리케이션에서는 JWT가 cookie로 전송되므로 크기가 성능에 영향을 줄 수 있습니다.

Custom Access Token Hook 구현

Hook은 JWT가 생성되기 전에 실행되는 PL/pgSQL 함수입니다. claims를 추가, 수정 또는 삭제할 수 있습니다.

-- Hook 함수 생성
CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event jsonb)
RETURNS jsonb
LANGUAGE plpgsql
AS $$
DECLARE
  claims jsonb;
  user_role text;
  tenant_id uuid;
BEGIN
  -- event에서 claims 가져오기
  claims := event-&gt;'claims';

  -- user_roles 테이블에서 사용자 역할 가져오기
  SELECT role INTO user_role
  FROM public.user_roles
  WHERE user_id = (event-&gt;&gt;'user_id')::uuid;

  -- 사용자 역할이 있으면 claims에 추가
  IF user_role IS NOT NULL THEN
    claims := jsonb_set(claims, '{user_role}', to_jsonb(user_role));
  END IF;

  -- users 테이블에서 tenant_id 가져오기
  SELECT tenant_id INTO tenant_id
  FROM public.users
  WHERE id = (event-&gt;&gt;'user_id')::uuid;

  IF tenant_id IS NOT NULL THEN
    claims := jsonb_set(claims, '{tenant_id}', to_jsonb(tenant_id));
  END IF;

  -- 수정한 claims 반환
  RETURN jsonb_build_object('claims', claims);
END;
$$;

-- supabase_auth_admin에 이 함수의 실행 권한 부여
GRANT EXECUTE ON FUNCTION public.custom_access_token_hook(jsonb)
TO supabase_auth_admin;

-- Supabase Dashboard에서 이 Hook 활성화
-- Authentication &gt; Hooks &gt; Custom Access Token &gt; 위 함수 선택

Hook의 입력 매개변수 event에는 다음 정보가 포함됩니다.

  • user_id: 현재 사용자 UUID
  • claims: 현재 JWT claims
  • authentication_method: 로그인 방식(password, oauth, sso/saml 등)

반환된 claims는 최종 JWT에 병합됩니다.

RBAC 테이블 구조 설계

완전한 역할 기반 권한 시스템에는 여러 테이블이 필요합니다.

-- 역할 유형 정의
CREATE TYPE app_role AS ENUM ('admin', 'moderator', 'user');

-- 권한 유형 정의
CREATE TYPE app_permission AS ENUM (
  'posts.delete',      -- 모든 게시물 삭제
  'posts.pin',         -- 게시물 고정
  'users.manage',      -- 사용자 관리
  'settings.edit'      -- 설정 편집
);

-- 사용자-역할 테이블
CREATE TABLE public.user_roles (
  user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
  role app_role NOT NULL,
  PRIMARY KEY (user_id, role)
);

-- 역할-권한 테이블
CREATE TABLE public.role_permissions (
  role app_role NOT NULL,
  permission app_permission NOT NULL,
  PRIMARY KEY (role, permission)
);

-- 기본 권한 삽입
INSERT INTO role_permissions (role, permission) VALUES
  ('admin', 'posts.delete'),
  ('admin', 'posts.pin'),
  ('admin', 'users.manage'),
  ('admin', 'settings.edit'),
  ('moderator', 'posts.delete'),
  ('moderator', 'posts.pin');

authorize() 권한 확인 함수

역할과 권한 테이블을 만들었다면 사용자에게 특정 권한이 있는지 확인하는 함수가 필요합니다.

CREATE OR REPLACE FUNCTION public.authorize(
  requested_permission app_permission
)
RETURNS boolean
LANGUAGE plpgsql
STABLE SECURITY DEFINER
AS $$
DECLARE
  user_role app_role;
BEGIN
  -- JWT에서 사용자 역할 가져오기
  SELECT (auth.jwt()-&gt;&gt;'user_role')::app_role
  INTO user_role;

  -- JWT에 역할이 없으면 false 반환
  IF user_role IS NULL THEN
    RETURN false;
  END IF;

  -- 해당 역할에 요청한 권한이 있는지 확인
  RETURN EXISTS (
    SELECT 1 FROM public.role_permissions
    WHERE role = user_role
    AND permission = requested_permission
  );
END;
$$;

-- authenticated 역할에 권한 부여
GRANT EXECUTE ON FUNCTION public.authorize(app_permission)
TO authenticated;

RLS Policy에서 authorize() 호출

이제 Policy에서 authorize()를 사용해 권한을 확인할 수 있습니다.

-- admin/moderator만 게시물을 삭제할 수 있습니다.
CREATE POLICY "Role-based delete" ON posts
FOR DELETE TO authenticated
USING (
  authorize('posts.delete') OR auth.uid() = author_id
);

-- admin만 게시물을 고정할 수 있습니다.
CREATE POLICY "Admin pin posts" ON posts
FOR UPDATE TO authenticated
USING (
  NOT is_pinned OR authorize('posts.pin')
);

첫 번째 Policy의 의미는 다음과 같습니다. 사용자가 posts.delete 권한이 있거나(admin/moderator) 게시물 작성자라면 게시물을 삭제할 수 있습니다.

두 번째 Policy는 게시물을 수정해 is_pinned를 true로 바꾸려면 posts.pin 권한이 있어야 한다는 뜻입니다.

프런트엔드에서 Custom Claims 읽기

Custom Claims는 JWT에 있으므로 프런트엔드에서 access_token을 디코딩해야 읽을 수 있습니다.

import { jwtDecode } from 'jwt-decode'

// session 가져오기
const { data: { session } } = await supabase.auth.getSession()

if (session) {
  const decoded = jwtDecode(session.access_token)
  console.log('사용자 역할:', decoded.user_role)
  console.log('테넌트 ID:', decoded.tenant_id)
}

jwtDecode는 JWT를 디코딩할 뿐 서명을 검증하지 않는다는 점에 주의하세요. 프런트엔드에서는 검증할 필요가 없습니다. 검증은 Supabase 서버에서 수행됩니다.

5. 기업 SaaS 전체 시나리오 실전

이제 앞에서 배운 내용을 조합해 완전한 기업용 SaaS 인증 솔루션을 구축해 보겠습니다.

이 SaaS 제품에는 두 종류의 사용자가 있습니다.

  1. 기업 사용자: 고객사의 SSO(Okta/Azure AD)를 통해 로그인하며 특정 테넌트에 속합니다.
  2. 개인 사용자: Google/GitHub OAuth로 로그인하고 어느 테넌트에도 속하지 않으며 공개 리소스만 이용할 수 있습니다.

데이터 격리 요구 사항은 명확합니다. 기업 사용자는 자신이 속한 테넌트의 데이터만 볼 수 있고, 개인 사용자는 기업 데이터를 볼 수 없어야 합니다.

사용자 테이블 설계

CREATE TABLE public.users (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  email TEXT NOT NULL,

  -- 인증 출처
  auth_type TEXT NOT NULL DEFAULT 'oauth',  -- 'oauth' 또는 'sso'

  -- SSO 사용자 전용 필드
  sso_provider_id TEXT,  -- SSO 연결 ID
  tenant_id UUID,        -- 소속 테넌트

  -- 기본 정보
  full_name TEXT,
  avatar_url TEXT,

  -- 타임스탬프
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now()
);

-- 테넌트 테이블
CREATE TABLE public.tenants (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL,
  sso_provider_id TEXT NOT NULL UNIQUE,  -- SSO 연결과 연계
  plan_type TEXT NOT NULL DEFAULT 'team',  -- 'team' 또는 'enterprise'
  created_at TIMESTAMPTZ DEFAULT now()
);

Custom Access Token Hook에서 통합 처리

Hook은 두 사용자 유형을 구분해야 합니다.

CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event jsonb)
RETURNS jsonb
LANGUAGE plpgsql
AS $$
DECLARE
  claims jsonb;
  user_record RECORD;
BEGIN
  claims := event-&gt;'claims';

  -- 사용자 정보 가져오기
  SELECT auth_type, sso_provider_id, tenant_id, role
  INTO user_record
  FROM public.users
  WHERE id = (event-&gt;&gt;'user_id')::uuid;

  -- 사용자가 없으면(방금 가입한 경우) 건너뜁니다.
  IF user_record IS NULL THEN
    RETURN jsonb_build_object('claims', claims);
  END IF;

  -- 인증 유형 추가
  claims := jsonb_set(claims, '{auth_type}', to_jsonb(user_record.auth_type));

  -- SSO 사용자라면 tenant_id와 sso_provider_id 추가
  IF user_record.auth_type = 'sso' THEN
    IF user_record.tenant_id IS NOT NULL THEN
      claims := jsonb_set(claims, '{tenant_id}', to_jsonb(user_record.tenant_id));
    END IF;
    IF user_record.sso_provider_id IS NOT NULL THEN
      claims := jsonb_set(claims, '{sso_provider_id}', to_jsonb(user_record.sso_provider_id));
    END IF;
  END IF;

  -- 사용자 역할이 있으면 추가
  IF user_record.role IS NOT NULL THEN
    claims := jsonb_set(claims, '{user_role}', to_jsonb(user_record.role));
  END IF;

  RETURN jsonb_build_object('claims', claims);
END;
$$;

로그인 후 사용자 생성 흐름

OAuth 로그인과 SSO 로그인 모두 auth.users 테이블에 레코드를 생성합니다. 로그인에 성공한 뒤에는 사용자 정보를 public.users 테이블에 동기화해야 합니다.

Auth Hooks의 mfa_verification_hook을 사용하거나 비즈니스 코드에서 처리할 수 있습니다.

// auth callback 페이지에서 로그인 성공 후
const { data: { user } } = await supabase.auth.getUser()

if (user) {
  // 사용자가 public.users에 이미 있는지 확인
  const { data: existingUser } = await supabase
    .from('users')
    .select('id')
    .eq('id', user.id)
    .single()

  if (!existingUser) {
    // 로그인 방식 판단
    const authType = user.app_metadata?.provider || 'oauth'

    // 사용자 레코드 생성
    await supabase.from('users').insert({
      id: user.id,
      email: user.email,
      auth_type: authType.startsWith('sso') ? 'sso' : 'oauth',
      sso_provider_id: user.app_metadata?.sso_provider_id,
      tenant_id: null,  // 이후 관리자가 할당
      full_name: user.user_metadata?.full_name,
      avatar_url: user.user_metadata?.avatar_url
    })
  }
}

통합 RLS Policy

비즈니스 테이블에는 OAuth 사용자와 SSO 사용자를 모두 처리하는 통합 RLS Policy가 필요합니다.

-- projects 테이블이 있다고 가정
CREATE TABLE public.projects (
  id UUID PRIMARY KEY,
  tenant_id UUID REFERENCES tenants(id),
  name TEXT NOT NULL,
  is_public BOOLEAN DEFAULT false,
  created_by UUID REFERENCES users(id)
);

ALTER TABLE public.projects ENABLE ROW LEVEL SECURITY;

-- 전역 격리 Policy(RESTRICTIVE)
CREATE POLICY "Tenant or public access" ON public.projects
AS RESTRICTIVE TO authenticated
USING (
  -- SSO 사용자: tenant_id가 반드시 일치
  (auth.jwt()-&gt;&gt;'auth_type' = 'sso'
   AND tenant_id = (auth.jwt()-&gt;&gt;'tenant_id')::uuid)
  OR
  -- OAuth 사용자: 공개 프로젝트만 조회
  (auth.jwt()-&gt;&gt;'auth_type' = 'oauth' AND is_public = true)
);

-- 조회 허용(PERMISSIVE)
CREATE POLICY "Users can view" ON public.projects
FOR SELECT TO authenticated
USING (true);

-- 생성 허용(테넌트 사용자만 생성 가능)
CREATE POLICY "Tenant users can create" ON public.projects
FOR INSERT TO authenticated
WITH CHECK (
  auth.jwt()-&gt;&gt;'auth_type' = 'sso'
  AND tenant_id = (auth.jwt()-&gt;&gt;'tenant_id')::uuid
);

-- 편집 허용(생성자 또는 관리자)
CREATE POLICY "Creators or admins can edit" ON public.projects
FOR UPDATE TO authenticated
USING (
  created_by = auth.uid()
  OR authorize('projects.edit')
);

이 Policy의 로직은 다음과 같습니다.

  • SSO 사용자는 자신이 속한 테넌트의 프로젝트만 볼 수 있습니다.
  • OAuth 사용자는 공개 프로젝트만 볼 수 있습니다.
  • SSO 사용자만 프로젝트를 생성할 수 있습니다(테넌트 소속 필요).
  • 생성자 또는 projects.edit 권한이 있는 관리자만 편집할 수 있습니다.

기업 고객 관리자의 테넌트 할당

SSO 사용자가 처음 로그인했을 때 tenant_id는 null입니다. 기업 관리자가 수동으로 할당해야 합니다.

-- 관리자가 사용자를 테넌트에 추가
UPDATE public.users
SET tenant_id = '&lt;테넌트UUID&gt;'
WHERE id = '&lt;사용자UUID&gt;';

-- 사용자 역할 설정
INSERT INTO public.user_roles (user_id, role)
VALUES ('&lt;사용자UUID&gt;', 'admin');

이 과정은 관리 화면으로 만들 수도 있고 Auth Hooks를 이용해 자동화할 수도 있습니다. 예를 들어 SSO provider를 익명으로 테넌트에 매핑할 수 있습니다.

전체 흐름

사용자 로그인

   ├─ OAuth 사용자
   │    │
   │    ├─ Google/GitHub 콜백
   │    ├─ Supabase가 auth.users 생성
   │    ├─ 비즈니스 코드가 public.users 생성(auth_type='oauth')
   │    └─ JWT: { auth_type: 'oauth' }
   │    │
   │    └─ RLS: is_public=true인 데이터만 조회

   └─ SSO 사용자

        ├─ Okta/Azure AD SAML 콜백
        ├─ Supabase가 auth.users 생성(sso_provider_id 포함)
        ├─ 비즈니스 코드가 public.users 생성(auth_type='sso')
        ├─ 관리자가 tenant_id 할당
        ├─ Custom Hook이 JWT에 tenant_id 추가
        └─ JWT: { auth_type: 'sso', tenant_id: '...' }

        └─ RLS: tenant_id가 일치하는 데이터만 조회

OAuth 소셜 로그인에서 시작해 SAML SSO 기업 연동으로 확장하고, 마지막으로 RLS와 Custom Claims를 이용해 완전한 권한 체계를 구축했습니다. 이 구성은 개인용 제품부터 기업용 SaaS까지 다양한 시나리오를 지원할 수 있습니다.

몇 가지 핵심 결정 기준을 정리하면 다음과 같습니다.

사용자가 주로 개인 소비자라면 OAuth(Google/GitHub)만으로 충분합니다. 설정이 간단하고 사용자에게 익숙하며 유지보수 비용도 낮습니다. 사용자가 자신의 데이터에만 접근할 수 있게 하는 기본 RLS면 충분합니다.

B2B SaaS를 만든다면 SSO는 필수 기능입니다. 기업 고객이 이를 요구하며, 없으면 후보에서 바로 제외될 수도 있습니다. Okta/Azure AD/Google Workspace 연동을 준비하고 멀티테넌트 아키텍처를 미리 고려하세요.

복잡한 기업용 애플리케이션을 만든다면 RBAC와 RLS의 조합이 표준적인 해법입니다. 역할-권한 테이블, Custom Claims, authorize() 함수를 함께 사용하면 “관리자는 삭제할 수 있지만 고정할 수 없음”, “테넌트 구성원은 편집할 수 있지만 삭제할 수 없음” 같은 세밀한 권한 요구도 처리할 수 있습니다.

다음 단계로는 먼저 OAuth부터 설정해 기본 흐름을 완성하세요. 프로젝트가 성장하고 기업 고객의 요구가 생기면 SSO와 RBAC를 차례로 추가하면 됩니다. Supabase의 아키텍처는 점진적인 확장을 지원하므로 처음부터 모든 기능을 구성할 필요는 없습니다.

Supabase Auth 엔터프라이즈 설정 전체 과정

OAuth 소셜 로그인부터 SAML SSO 기업 연동, RLS 멀티테넌트 권한 격리까지 이어지는 전체 설정 과정

⏱️ Estimated time: 2 hr

  1. 1

    Step 1: OAuth Provider 설정(Google/GitHub/Apple)

    1. Provider 콘솔에서 OAuth App 생성(Google Cloud Console / GitHub Settings)
    2. 콜백 주소 설정: https://&lt;프로젝트ref&gt;.supabase.co/auth/v1/callback
    3. Supabase Dashboard에서 Provider를 활성화하고 Client ID와 Secret 입력
    4. 코드에서 signInWithOAuth()를 호출하고 scopes와 redirectTo 지정
  2. 2

    Step 2: SAML SSO 기업 연동 설정

    1. 기업 IdP(Okta/Azure AD)에서 Metadata 파일 또는 URL 확보
    2. CLI로 SSO 연결 추가: supabase sso add --type saml --domains company.com
    3. Attribute Mapping을 설정해 email, name 등의 필드 매핑
    4. 로그인 흐름 테스트: 사용자가 이메일을 입력하면 IdP로 자동 이동
  3. 3

    Step 3: RLS 멀티테넌트 격리 구현

    1. 비즈니스 테이블에 tenant_id 필드 추가
    2. RLS 활성화: ALTER TABLE projects ENABLE ROW LEVEL SECURITY
    3. RESTRICTIVE Policy를 생성해 전역 테넌트 필터링 구현
    4. PERMISSIVE Policy로 구체적인 작업 권한(SELECT/INSERT/UPDATE) 제어
    5. tenant_id 필드에 인덱스를 추가해 성능 최적화
  4. 4

    Step 4: Custom Access Token Hook 설정

    1. public.custom_access_token_hook() 함수 생성
    2. 함수에서 tenant_id, user_role 등의 custom claims 추가
    3. supabase_auth_admin에 함수 실행 권한 부여
    4. Supabase Dashboard에서 Hook 활성화(Authentication &gt; Hooks)
    5. 프런트엔드에서 jwtDecode()로 custom claims 읽기
  5. 5

    Step 5: RBAC 권한 제어 구현

    1. user_roles와 role_permissions 테이블 생성
    2. app_role과 app_permission 열거형 정의
    3. 사용자 권한을 확인하는 authorize() 함수 생성
    4. RLS Policy에서 authorize('permission.name') 호출
    5. JWT의 user_role에 따라 프런트엔드 UI 요소 표시/숨김

FAQ

OAuth와 SAML SSO는 무엇이 다른가요? 무엇을 선택해야 하나요?
OAuth는 개인 소비자용 앱에 적합합니다. 사용자는 Google/GitHub 계정으로 로그인할 수 있고 설정이 간단하며 유지보수 비용도 낮습니다. SAML SSO는 B2B 기업용 앱에 적합합니다. 기업 고객은 직원이 회사의 통합 계정(Okta/Azure AD)으로 로그인하기를 원하며, 계정은 기업이 관리하고 직원이 퇴사하면 자동으로 비활성화됩니다.

B2B SaaS를 만든다면 SSO는 필수 기능이고, 개인용 제품이라면 OAuth만으로 충분합니다.
RLS를 활성화한 뒤 쿼리가 빈 결과를 반환하면 어떻게 해야 하나요?
이는 RLS의 기본 동작입니다. 활성화하면 기본적으로 모든 접근이 거부됩니다. 접근을 허용하려면 Policy를 생성해야 합니다. 예: CREATE POLICY "Users view own posts" ON posts FOR SELECT TO authenticated USING (auth.uid() = author_id);
RLS Policy 성능이 낮을 때는 어떻게 최적화하나요?
세 가지 방향으로 최적화할 수 있습니다.

• Policy에서 사용하는 필드에 인덱스 생성: CREATE INDEX idx_tenant ON projects(tenant_id);
• Policy 안의 서브쿼리 제거: Custom Access Token Hook으로 tenant_id를 JWT에 넣고 auth.jwt()-&gt;&gt;'tenant_id'로 바로 추출
• 복잡한 로직을 SECURITY DEFINER 함수로 감싸면 PostgreSQL이 한 번만 실행하도록 최적화
JWT에 tenant_id, user_role 같은 사용자 정의 필드를 저장하려면 어떻게 하나요?
Supabase의 Custom Access Token Hook을 사용합니다.

1. PL/pgSQL 함수 custom_access_token_hook(event jsonb) 생성
2. 함수에서 jsonb_set()으로 custom claims 추가
3. Supabase Dashboard에서 Hook 활성화(Authentication &gt; Hooks &gt; Custom Access Token)
4. 프런트엔드에서 jwtDecode()로 JWT의 custom claims 읽기
멀티테넌트 SaaS에서 기업 사용자와 개인 사용자의 데이터를 어떻게 격리하나요?
핵심은 JWT에 auth_type('oauth' 또는 'sso')과 tenant_id를 저장하고, RLS Policy가 두 필드를 기준으로 데이터를 필터링하도록 하는 것입니다.

• SSO 사용자: tenant_id가 일치하는 테넌트 데이터만 조회
• OAuth 사용자: is_public=true인 공개 데이터만 조회

RESTRICTIVE Policy를 전역 필터로 사용하고 PERMISSIVE Policy로 구체적인 작업을 제어합니다.
Supabase는 Apple Sign In을 지원하나요? 설정이 복잡한가요?
지원하지만 Google/GitHub보다 설정이 복잡합니다.

1. Apple Developer Portal에서 Services ID 생성
2. 비공개 키(.p8 파일, 한 번만 다운로드 가능) 생성
3. Supabase Dashboard에 Team ID, Key ID, Services ID와 비공개 키 내용 입력

iOS 앱은 네이티브 Sign in with Apple API를 사용해 identity_token을 받은 뒤 Supabase의 signInWithIdTokenCredentials()에 전달할 수 있습니다.
기업 고객이 SSO를 요구하지만 어떤 IdP를 사용하는지 모르면 어떻게 하나요?
고객사의 IT 관리자에게 IdP Metadata 파일 또는 URL을 요청하세요. Okta/Azure AD/Google Workspace는 모두 이를 내보낼 수 있습니다. Metadata만 받으면 supabase sso add --metadata-file 명령으로 연결을 추가할 수 있으며, Supabase가 Metadata의 엔드포인트와 인증서를 자동으로 해석합니다.

9분 읽기 · 게시일: 2026년 4월 21일 · 수정일: 2026년 9월 4일

댓글

GitHub로 로그인하여 댓글을 남기세요

Easton BlogEaston Blog