Supabase Auth + RLS로 팀 RBAC 구현하기 — JWT claims 설계부터 역할별 접근 정책까지
SaaS를 만들다 보면 반드시 맞닥뜨리는 벽이 있습니다. "이 팀의 관리자만 문서를 삭제할 수 있어야 하고, 뷰어는 읽기만 가능해야 하는데… 이걸 어떻게 구현하지?" 처음엔 서버 코드에 if (user.role === 'admin') 같은 조건을 잔뜩 박아넣게 되는데, 그게 점점 늘어나면 어느 순간 권한 로직이 컨트롤러, 미들웨어, 서비스 여기저기 흩어지면서 유지보수가 지옥이 됩니다.
Supabase는 이 문제를 데이터베이스 레벨에서 해결하는 방법을 제공합니다. PostgreSQL의 Row Level Security(RLS)와 JWT 커스텀 클레임을 조합하면, 서버 코드에 권한 체크를 거의 쓰지 않고도 "이 행은 이 역할의 사람만 볼 수 있다"는 규칙을 테이블 단위로 강제할 수 있습니다. 클라이언트 SDK에서 쿼리하든, 서버 Edge Function에서 쿼리하든 동일한 정책이 자동으로 적용됩니다.
이 글에서는 JWT 커스텀 클레임 설계부터 팀 멤버십 테이블 구성, 역할별 RLS 정책 분기, 그리고 성능 최적화 패턴까지 실제로 동작하는 코드와 함께 단계별로 살펴봅니다. 글이 끝날 때 실제로 동작하는 팀 권한 시스템의 뼈대를 갖게 됩니다.
핵심 개념
세 계층이 맞물리는 구조
팀 기반 권한 시스템은 세 가지 계층이 함께 동작합니다.
[1] Custom Access Token Hook (PostgreSQL 함수)
→ 로그인 시 team_members 조회 → JWT app_metadata에 역할 배열 삽입
[2] JWT Claims (app_metadata.team_roles)
→ [{team_id, role}, ...] 형태로 토큰 안에 이미 존재
[3] Row Level Security (RLS)
→ 쿼리마다 auth.jwt() / auth.uid()로 역할 확인 → 행 단위 필터 적용auth.uid()와 auth.jwt()는 Supabase가 제공하는 내장 헬퍼 함수입니다. 현재 요청에 포함된 JWT를 파싱해서 사용자 ID와 클레임을 꺼내주는 역할을 하고, RLS 정책 안에서 자유롭게 쓸 수 있습니다.
이 흐름에서 핵심은 두 번째 계층입니다. 역할 정보가 토큰에 이미 담겨 있으면, 매 쿼리마다 DB에 "이 사람이 어떤 역할이지?" 하고 물어볼 필요가 없어집니다.
app_metadata vs user_metadata — 이건 헷갈리면 보안 취약점
저도 처음엔 이 둘의 차이를 대수롭지 않게 봤는데, 잘못 쓰면 권한 상승 취약점으로 이어집니다.
핵심 구분:
raw_app_meta_data는 서버만 수정할 수 있어서 인가(Authorization)에 사용해도 안전합니다. 반면raw_user_meta_data는 클라이언트에서 직접 수정이 가능하기 때문에 권한 제어에 절대 사용하면 안 됩니다.
raw_app_meta_data → 서버만 수정 가능 → 인가(Authorization)에 사용 ✅
raw_user_meta_data → 사용자가 직접 수정 가능 → 인가에 사용 금지 ❌RLS 정책에서 역할을 읽을 때는 반드시 이렇게 씁니다.
(auth.jwt() -> 'app_metadata' ->> 'role')여기서 -> 연산자는 JSONB 값을 그대로 반환하고, ->>는 텍스트 값으로 반환합니다. 역할을 문자열 비교해야 하므로 ->> 를 써야 합니다.
실전 적용
1단계: 스키마 설계
먼저 세 개의 테이블이 필요합니다. 팀, 팀 멤버십(역할 포함), 그리고 팀 소유 리소스입니다.
-- 팀 테이블
CREATE TABLE teams (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
-- 팀 멤버십 테이블 (역할 포함)
CREATE TABLE team_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
role TEXT CHECK (role IN ('admin', 'member', 'viewer')) NOT NULL,
UNIQUE(team_id, user_id)
);
-- Hook 타임아웃 방지를 위한 인덱스 (필수)
CREATE INDEX ON team_members(user_id);
CREATE INDEX ON team_members(team_id, user_id);
-- 팀 소유 리소스 예시
CREATE TABLE team_documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
title TEXT NOT NULL,
content TEXT,
created_by UUID REFERENCES auth.users(id)
);UNIQUE(team_id, user_id) 제약이 중요합니다. 한 사람이 같은 팀에 두 가지 역할로 등록되는 상황을 막아줍니다. 인덱스는 Custom Access Token Hook이 실행될 때 user_id 조회를 빠르게 하기 위해 반드시 함께 생성해두는 게 좋습니다.
| 테이블 | 역할 |
|---|---|
teams |
팀 기본 정보 |
team_members |
사용자 ↔ 팀 ↔ 역할 연결. RLS 정책의 권한 근거 |
team_documents |
팀이 소유한 리소스 예시. team_id로 소유 팀 식별 |
2단계: Hook으로 JWT에 역할 주입하기
이 함수를 작성하고 대시보드에 등록하면, 이후 발급되는 모든 JWT에 팀 역할 정보가 자동으로 포함됩니다.
CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event JSONB)
RETURNS JSONB
LANGUAGE plpgsql
STABLE -- 동일 트랜잭션 내에서 결과가 불변임을 선언. 플래너가 호출 횟수를 최적화할 수 있음
AS $$
DECLARE
claims JSONB;
user_id UUID;
team_roles JSONB;
BEGIN
user_id := (event->>'user_id')::UUID;
-- 유저의 모든 팀 역할을 배열로 조회
SELECT jsonb_agg(
jsonb_build_object('team_id', team_id::TEXT, 'role', role)
)
INTO team_roles
FROM team_members
WHERE team_members.user_id = user_id;
claims := event->'claims';
-- app_metadata에 팀 역할 배열 삽입 (팀이 없는 유저는 빈 배열)
claims := jsonb_set(
claims,
'{app_metadata, team_roles}',
COALESCE(team_roles, '[]'::JSONB)
);
RETURN jsonb_set(event, '{claims}', claims);
END;
$$;
-- Hook이 실행될 수 있도록 supabase_auth_admin에 실행 권한 부여
GRANT EXECUTE ON FUNCTION public.custom_access_token_hook TO supabase_auth_admin;GRANT 구문을 빠뜨리면 Hook이 등록되어도 동작하지 않습니다. Supabase 대시보드에서 Authentication → Hooks → Custom Access Token Hook 메뉴에서 위 함수를 연결한 뒤, 로그인 후 JWT를 디코딩해서
app_metadata.team_roles배열이 들어있는지 확인해보면 좋습니다.
이후 발급된 JWT를 디코딩하면 이런 구조를 볼 수 있습니다.
{
"app_metadata": {
"team_roles": [
{ "team_id": "uuid-a", "role": "admin" },
{ "team_id": "uuid-b", "role": "viewer" }
]
}
}3단계: SECURITY DEFINER 헬퍼 함수로 순환 참조 방지
솔직히 처음 RLS를 짜다가 "왜 쿼리가 안 돌아오지?" 하면서 한참 헤맨 게 이 순환 참조 문제였습니다. team_members에 RLS가 걸려있는데, 정책 안에서 다시 team_members를 조회하면 무한 루프가 발생합니다.
SECURITY DEFINER 함수를 쓰면 해결됩니다. 이 속성을 붙이면 함수가 호출자의 권한이 아닌 함수 소유자(postgres)의 권한으로 실행되어 RLS를 우회합니다.
CREATE OR REPLACE FUNCTION get_user_team_role(check_team_id UUID)
RETURNS TEXT
LANGUAGE sql
STABLE -- 동일 트랜잭션 내 반복 호출 시 결과 캐싱 허용
SECURITY DEFINER
SET search_path = public -- search_path 조작을 통한 보안 취약점 방지
AS $$
SELECT role
FROM team_members
WHERE team_id = check_team_id
AND user_id = auth.uid()
LIMIT 1;
$$;
SET search_path = public은 SECURITY DEFINER 함수에서 필수입니다. 이를 생략하면 공격자가 search_path를 조작해 다른 스키마의 함수를 대신 실행시키는 취약점이 생길 수 있습니다.
참고로, team_members 테이블 자체에 RLS를 적용하고 싶다면 다음처럼 이 함수를 우회 경로로 활용하는 SELECT 정책을 붙일 수 있습니다.
ALTER TABLE team_members ENABLE ROW LEVEL SECURITY;
-- 본인의 team_members 행만 조회 가능 (SECURITY DEFINER 함수 내부는 이 정책의 영향을 받지 않음)
CREATE POLICY "team_members_select_own"
ON team_members FOR SELECT
USING (user_id = auth.uid());4단계: 역할별 RLS 정책 분기
헬퍼 함수까지 준비됐다면, 이제 역할에 따라 SELECT / INSERT / UPDATE / DELETE를 분기하는 정책을 붙일 수 있습니다.
ALTER TABLE ... ENABLE ROW LEVEL SECURITY를 먼저 실행하지 않으면 아래 정책들은 아무 효과가 없습니다. RLS는 테이블에 기본으로 비활성화되어 있습니다.
-- RLS 활성화 (이게 먼저!)
ALTER TABLE team_documents ENABLE ROW LEVEL SECURITY;
-- 뷰어 이상: 자기 팀 문서 조회 가능
-- get_user_team_role()은 행마다 호출되므로, 결과 셋이 클 경우 성능 영향 있음
CREATE POLICY "team_docs_select"
ON team_documents FOR SELECT
USING (
get_user_team_role(team_id) IN ('admin', 'member', 'viewer')
);
-- 멤버 이상: 문서 생성 가능
CREATE POLICY "team_docs_insert"
ON team_documents FOR INSERT
WITH CHECK (
get_user_team_role(team_id) IN ('admin', 'member')
AND created_by = auth.uid()
);
-- 멤버: 자신이 만든 문서만 수정
CREATE POLICY "team_docs_update_member"
ON team_documents FOR UPDATE
USING (
get_user_team_role(team_id) = 'member'
AND created_by = auth.uid()
);
-- 관리자: 팀 내 모든 문서 수정/삭제 가능
CREATE POLICY "team_docs_update_admin"
ON team_documents FOR UPDATE
USING (get_user_team_role(team_id) = 'admin');
CREATE POLICY "team_docs_delete_admin"
ON team_documents FOR DELETE
USING (get_user_team_role(team_id) = 'admin');같은 테이블에 여러 UPDATE 정책이 붙을 경우, SELECT는 OR 조건으로 합산됩니다. 즉, team_docs_update_member와 team_docs_update_admin 두 정책 중 하나만 만족해도 UPDATE가 허용됩니다.
5단계: JWT 클레임 기반 고성능 정책
역할 변경이 잦지 않고, JWT 크기 문제가 없는 상황이라면 DB 조회 없이 JWT 클레임만으로 정책을 작성하는 방법도 있습니다. get_user_team_role() 함수 호출 자체가 없어지니 쿼리 성능이 크게 향상됩니다.
CREATE POLICY "team_docs_select_from_jwt"
ON team_documents FOR SELECT
USING (
EXISTS (
SELECT 1
FROM jsonb_array_elements(
(auth.jwt() -> 'app_metadata' -> 'team_roles')
) AS tr
-- team_id를 TEXT로 캐스팅해 JWT의 문자열 team_id와 타입을 통일
WHERE (tr->>'team_id') = team_id::TEXT
AND (tr->>'role') IN ('admin', 'member', 'viewer')
)
);커뮤니티 사례에 따르면 이 방식으로 전환했을 때 쿼리 응답 시간이 수백ms에서 한 자릿수 ms 수준으로 줄어드는 경우도 있습니다. 다만 토큰 만료 전까지는 역할 변경이 즉시 반영되지 않는다는 트레이드오프가 있습니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| DB 레벨 강제 보안 | 애플리케이션 코드를 우회하더라도 DB 정책은 항상 작동합니다 |
| 일관성 | 클라이언트 SDK, 서버 Edge Function, 대시보드 직접 쿼리 모두 동일한 정책이 적용됩니다 |
| 코드 간소화 | 서버 코드에 별도 권한 체크 미들웨어를 작성할 필요가 없어집니다 |
| 성능 (JWT 방식) | JWT 클레임 방식은 추가 DB 조회 없이 인가가 가능합니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 역할 변경 즉시 반영 불가 | JWT는 만료 전까지 구 역할을 유지합니다 (기본 1시간) | 토큰 만료 시간 단축 + 역할 변경 시 강제 로그아웃 처리 |
| JWT 크기 문제 | 팀이 많은 사용자는 team_roles 배열이 커져 토큰이 비대해집니다 |
현재 활성 팀만 포함하거나 DB 조회 방식으로 폴백 |
| Hook 타임아웃 | PostgreSQL Hook은 약 2초, HTTP Hook은 약 5초 제한이 있습니다 | 역할 조회 쿼리에 인덱스 최적화 필수 |
| 순환 참조 | team_members에 RLS 적용 시 정책 내에서 재조회하면 무한 루프 발생 |
SECURITY DEFINER 함수로 우회 |
| 디버깅 난이도 | 어느 정책에서 막혔는지 파악하기 어렵습니다 | SET LOCAL role = authenticated 후 EXPLAIN ANALYZE 활용 |
RLS 정책 디버깅 팁: Supabase SQL 편집기에서
SET LOCAL role = authenticated; SET LOCAL request.jwt.claims = '{"sub": "user-uuid", "app_metadata": {...}}';형태로 특정 사용자를 시뮬레이션해서 쿼리를 직접 테스트해볼 수 있습니다.
아키텍처 선택 기준
팀 수 × 역할 조합이 단순하다 → JWT claims 방식 (고성능, 구현 단순)
역할이 자주 바뀌거나 즉각 반영 필요 → DB 조회 방식 (SECURITY DEFINER 함수)
두 가지 혼용 → JWT에는 단순 플래그, 세밀한 권한은 DB 조회실무에서 가장 흔한 실수
-
user_metadata에 역할 저장: 가장 많이 보이는 실수입니다.user_metadata는 클라이언트에서 직접 수정 가능하기 때문에 권한 상승 공격에 그대로 노출됩니다. 역할 정보는 반드시app_metadata에 서버 측에서만 기록해야 합니다. -
team_members에 RLS 적용 후 정책 내에서 직접 조회: RLS가 활성화된 테이블을 정책 내에서 재조회하면 무한 순환이 발생합니다.SECURITY DEFINER함수로 감싸서 조회하는 패턴을 사용해야 합니다. -
역할 변경 후 토큰 갱신 미처리: 관리자에서 뷰어로 강등했는데 기존 토큰이 살아있어서 여전히 관리자 권한으로 동작하는 상황이 생길 수 있습니다. 역할 변경 이벤트에서 해당 사용자의 세션을 강제 만료시키는 로직을 함께 구현해두는 게 좋습니다.
마치며
Supabase Auth + RLS 조합은 팀 기반 권한 시스템을 애플리케이션 코드가 아닌 데이터베이스 레벨에서 강제하는 가장 확실한 방법입니다.
시작하기 전에 이것만 확인해두면 삽질을 많이 줄일 수 있습니다.
-
스키마 생성 전 체크:
team_members에 인덱스(user_id,team_id + user_id)를 함께 생성했는지,role컬럼에CHECK제약이 있는지 확인합니다. -
Hook 등록 후 체크:
supabase_auth_admin에 GRANT를 줬는지, 로그인 후 JWT를 디코딩해app_metadata.team_roles가 들어있는지 확인합니다. -
RLS 정책 작성 전 체크:
ALTER TABLE ... ENABLE ROW LEVEL SECURITY를 먼저 실행했는지,SECURITY DEFINER함수에SET search_path = public이 붙어있는지 확인합니다.
Supabase Dashboard의 RLS 정책 AI 프롬프트 기능(공식 문서)을 활용하면 정책 초안을 빠르게 잡을 수 있으니 참고해보시면 좋습니다.
참고 자료
- Custom Claims & Role-based Access Control (RBAC) | Supabase Docs
- Custom Access Token Hook | Supabase Docs
- Row Level Security | Supabase Docs
- Auth Hooks | Supabase Docs
- JSON Web Token (JWT) | Supabase Docs
- Supabase RLS Best Practices: Production Patterns for Secure Multi-Tenant Apps | MakerKit
- Using Postgres RLS for a team invite system with Supabase | Boardshape Engineering
- Supabase Row Level Security in Production: Patterns That Actually Work | DEV Community
- Multi-Tenant Applications with RLS on Supabase | AntStack
- Role-Based Access Control (RBAC) in Next.js Supabase | MakerKit Docs
- Supabase MVP Architecture in 2026: Practical Patterns | Valtorian