Dev & CloudGuide

Supabase RLS 설정과 검증

사용자 A의 데이터를 B가 볼 수 없는지 직접 확인하기

로그인한 사용자가 자기 행만 읽고 쓰게 만드는 RLS 정책과 GRANT 설정을 만들고, 로컬에서 pgTAP로 허용과 거부를 검증한 뒤 정책을 일부러 깨뜨려 테스트가 잡는지 확인하는 절차를 정리합니다.

선반 위에 녹색 카드 한 줄과 모래색 카드 한 줄이 아치형 문을 사이에 두고 놓여 있고, 왼쪽의 점토 인형이 녹색 카드 한 장을 들고 있는 3D 일러스트

목차
  1. 로그인과 접근 제어는 다른 문제입니다
  2. RLS가 하는 일과 하지 않는 일
  3. 새 테이블에는 권한이 자동으로 붙지 않을 수 있습니다
  4. 예제 구조와 실행 환경
  5. 로컬 스택 준비하기
  6. 테이블과 정책 만들기
  7. 허용과 거부가 실제로 어떻게 돌아오는가
  8. pgTAP로 검증 자동화하기
  9. 정책을 일부러 깨뜨려 테스트가 잡는지 보기
  10. 실습 끝내고 정리하기
  11. 테스트가 통과해도 남는 위험
  12. 운영 프로젝트에 적용하기 전에
  13. 마무리 정리

로그인 화면을 붙이고 나면 “이제 사용자별로 데이터가 나뉘겠지”라고 생각하기 쉽습니다. 그런데 로그인은 누구인지 확인할 뿐이고, 어느 행을 볼 수 있는지는 데이터베이스가 따로 정해야 합니다. 화면에서 남의 데이터를 숨겨 놓아도 같은 키로 API를 직접 호출하면 숨긴 데이터가 그대로 돌아오는 경우가 생깁니다.

이 글은 개인 메모 테이블 하나로 그 간격을 메웁니다. 사용자 A는 A의 메모만 읽고 쓰고, 사용자 B의 메모는 읽지도 고치지도 못하고, 로그인하지 않은 사람은 아무것도 하지 못하게 만듭니다. 그리고 그 말이 맞는지를 설명으로 끝내지 않고 로컬 Supabase에서 테스트로 확인합니다. 예제는 모두 내 컴퓨터의 Docker에서 도는 로컬 데이터베이스에서 실행합니다.

로그인과 접근 제어는 다른 문제입니다

용어부터 나눠 두면 뒤가 쉽습니다. 인증(Authentication)은 요청한 사람이 누구인지 확인하는 단계이고, 권한 부여(Authorization)는 그 사람이 어떤 데이터에 무엇을 할 수 있는지 정하는 단계입니다. Supabase Auth는 앞의 일을 하고, 뒤의 일은 Postgres의 권한과 RLS 정책이 합니다.

질문담당예
이 요청을 보낸 사람은 누구인가인증이메일 로그인, 소셜 로그인
이 테이블에 접근할 수 있는 역할인가GRANT 권한authenticated에게 select 허용
이 테이블의 어느 행을 볼 수 있는가RLS 정책user_id가 내 ID인 행만

브라우저에서 쓰는 publishable key는 코드에 들어가도 되는 키로 설계되어 있습니다. 그래서 키가 공개되어도 데이터가 안전하려면 테이블 쪽에서 막고 있어야 합니다. 화면에서 숨기는 것은 편의이고, 막는 것은 데이터베이스의 일입니다.

나무 책상 위 모니터 두 대에 코드가 표시되어 있고 한 사람이 키보드에 손을 올리고 있다

RLS가 하는 일과 하지 않는 일

RLS(Row Level Security)는 Postgres가 기본으로 가진 기능입니다. 테이블에 켜 두면 모든 읽기와 쓰기가 행마다 정책을 통과해야 하고, 통과하는 정책이 하나도 없으면 아무 행도 보이거나 바뀌지 않습니다. 정책 없이 RLS만 켜는 것이 곧 전체 거부라는 점이 출발점입니다.

Supabase에서 요청은 보통 세 역할 중 하나로 들어옵니다. 로그인하지 않은 요청은 anon, 로그인한 요청은 authenticated로 처리됩니다. 세 번째 service_role은 서버에서만 쓰는 관리자용이고 RLS를 우회합니다.

여기서 흔히 헷갈리는 것이 GRANT와 정책이 두 겹이라는 점입니다. Postgres는 먼저 그 역할이 테이블에 그 작업을 할 권한이 있는지 보고, 그다음에 정책으로 어느 행에 적용할지 가립니다. 권한이 없으면 정책을 읽기도 전에 42501 오류(permission denied for table ...)로 끝납니다. 반대로 권한이 넓은 상태에서 RLS를 켜지 않은 테이블은, 그 권한을 가진 역할에게 모든 행이 열려 있습니다.

테이블 상태조회 결과
RLS 꺼짐, 조회 권한 있음모든 행이 보임
RLS 켜짐, 정책 없음, 조회 권한 있음0행
RLS 켜짐, 조회 권한 없음permission denied for table 오류

세 줄 모두 로컬 데이터베이스에서 역할을 바꿔 가며 직접 조회한 결과입니다(첫 줄은 anon, 둘째 줄은 authenticated, 셋째 줄은 anon으로 조회). 정책이 없는 쪽은 오류가 아니라 빈 결과가 나온다는 점이 중요합니다. 뒤에서 테스트를 쓸 때 “오류가 났다”와 “0행이 나왔다”를 구분해서 검사해야 하는 이유가 여기에 있습니다.

요청이 데이터에 닿기까지의 길을 한 장으로 그리면 아래와 같습니다. 두 관문을 차례로 지나고, 막히는 방식이 관문마다 다릅니다.

요청이 권한과 정책을 차례로 지나는 길anon 또는 authenticated 역할의 요청은 먼저 테이블 권한(GRANT)이 있는지 확인받습니다. 권한이 없으면 permission denied 오류(42501)로 끝납니다. 권한이 있으면 RLS 정책이 행마다 허용 여부를 가립니다. 조회·수정·삭제에서 조건 밖인 행은 오류 없이 0행이 되고, 삽입이나 수정으로 들어오는 새 값이 with check를 어기면 RLS 위반 오류(42501)가 납니다. 모두 통과한 요청만 허용된 행에 읽기와 쓰기를 합니다.요청anon 또는authenticated권한(GRANT)이있는가역할별로 허용한 작업정책(RLS)이허용하는가행마다 검사허용된 행에만읽고 씀권한 오류permission denied42501조회·수정·삭제조건 밖 행은 0행(오류 없음)삽입·수정 값이with check 위반RLS 위반 오류
요청은 권한 단계와 정책 단계를 차례로 지나고, 막히는 방식은 오류와 0행으로 갈립니다.

새 테이블에는 권한이 자동으로 붙지 않을 수 있습니다

예제를 처음 돌렸을 때 가장 먼저 만난 것은 정책 오류가 아니라 권한 오류였습니다. 정책을 모두 만든 뒤 authenticated로 조회하자 permission denied for table notes가 나왔고, 힌트로 GRANT SELECT ON public.notes TO authenticated;가 함께 출력되었습니다. 이 CLI 버전의 로컬 스택은 새 테이블에 anon, authenticated, service_role의 권한을 자동으로 붙이지 않습니다.

이것은 로컬만의 사정이 아닙니다. 예전에는 public 스키마에 새 테이블을 만들면 세 역할에게 select, insert, update, delete가 기본으로 부여되어 만드는 즉시 Data API(supabase-js와 REST가 쓰는 자동 생성 API)로 접근할 수 있었습니다. 이 기본값은 바뀌는 중입니다. 2026년 5월 30일부터 새 프로젝트에서 점차 기본값이 되었고, 기존 프로젝트에는 2026년 10월 30일에 적용될 예정입니다. 이미 있는 테이블의 권한은 그대로이고, 그날 이후 새로 만드는 테이블에는 권한을 직접 줘야 합니다.

그래서 이 글의 마이그레이션에는 정책과 함께 grant 문이 들어 있습니다. 프로젝트를 만든 시기와 설정에 따라 기본 권한이 다르므로, 내 프로젝트가 어느 쪽인지는 가정하지 말고 직접 확인하는 것이 안전합니다. select * from pg_default_acl;을 조회하면 명시적으로 설정된 기본 권한이 보입니다. 이 글의 로컬 스택에서는 public 스키마의 새 테이블에 anon, authenticated, service_role 모두 select·insert·update·delete가 붙지 않았습니다.

예제 구조와 실행 환경

사용할 테이블은 개인 메모입니다. 교육용이라 가짜 사용자와 가짜 메일 주소만 씁니다.

필드목적
id메모 식별자
user_id메모 소유자(auth.users의 ID)
content메모 내용
created_at생성 시각

요구 조건은 네 가지입니다. A는 A의 메모만 조회할 수 있고, B는 B의 메모만 조회할 수 있고, 로그인하지 않은 사람은 개인 메모를 조회할 수 없고, 다른 사람의 메모는 고치거나 지울 수 없습니다.

실행 환경은 macOS의 Docker(29.7.2, Docker Desktop)와 Supabase CLI 2.108.0이고, 이 CLI가 띄운 로컬 데이터베이스는 Postgres 17.6입니다. 버전이 다르면 출력이나 기본 권한이 달라질 수 있습니다.

노트북 화면에 소스 코드와 디버거 창이 열려 있고, 뒤에 화분과 머그잔이 보이는 모습

로컬 스택 준비하기

이 실습은 새 폴더와 내 컴퓨터의 로컬 데이터베이스에서만 진행하며, 운영 프로젝트나 이미 쓰는 Supabase 프로젝트는 건드리지 않습니다. 다른 로컬 Supabase 스택이 실행 중이면 포트가 겹칠 수 있으니 먼저 supabase stop으로 멈춥니다. 작업은 새 폴더 안에서 시작합니다. 아래 명령은 파일을 만들고 Docker 컨테이너를 띄웁니다. supabase start는 처음 실행할 때 이미지를 내려받기 때문에 디스크 여유를 먼저 보는 것이 좋습니다. 이 글의 실행에서는 RLS 테스트에 필요하지 않은 edge-runtime, imgproxy, realtime, studio, vector를 빼고 시작했고, 그 상태에서 받은 Supabase 이미지의 크기를 docker images로 더하면 9GB 안팎이었습니다. 이전에 받아 둔 이미지가 일부 섞여 있어 처음부터 받는 양과는 다를 수 있습니다.

bash
mkdir notes-rls-lab && cd notes-rls-lab
supabase init
supabase start -x edge-runtime,imgproxy,realtime,studio,vector
supabase migration new create_notes

supabase init은 supabase/ 폴더와 config.toml을 만들고, supabase migration new create_notes는 supabase/migrations/ 아래에 타임스탬프가 붙은 빈 SQL 파일을 만듭니다. Supabase CLI가 설치되어 있고 Docker가 실행 중이어야 합니다. 시작이 끝나면 supabase status로 로컬 주소와 상태를 확인하고, 실행 중인 컨테이너는 docker ps로 보입니다. -x 뒤에는 이름이 맞는 컨테이너만 쓸 수 있습니다. 목록에 없는 이름(예: rest)은 경고만 나오고 무시되며, kong을 빼면 시작 후 상태 점검이 실패했습니다. 전부 받아도 괜찮다면 -x 없이 supabase start만 실행해도 같은 테스트가 돕니다.

테이블과 정책 만들기

방금 만들어진 마이그레이션 파일에 아래 SQL을 붙여 넣습니다.

sql
create table public.notes (
  id bigint generated always as identity primary key,
  user_id uuid not null default auth.uid() references auth.users (id) on delete cascade,
  content text not null,
  created_at timestamptz not null default now()
);

alter table public.notes enable row level security;

create policy "notes_select_own" on public.notes
  for select to authenticated
  using ((select auth.uid()) = user_id);

create policy "notes_insert_own" on public.notes
  for insert to authenticated
  with check ((select auth.uid()) = user_id);

create policy "notes_update_own" on public.notes
  for update to authenticated
  using ((select auth.uid()) = user_id)
  with check ((select auth.uid()) = user_id);

create policy "notes_delete_own" on public.notes
  for delete to authenticated
  using ((select auth.uid()) = user_id);

grant select, insert, update, delete on public.notes to authenticated;

한 줄씩 짚으면 이렇습니다.

user_id uuid not null default auth.uid()는 새 행을 만들 때 작성자 ID를 서버가 채웁니다. 클라이언트가 user_id를 보내지 않아도 되고, 보내더라도 정책이 걸러 냅니다. references auth.users ... on delete cascade는 사용자를 지우면 그 사용자의 메모도 함께 지우는 설정입니다.

alter table ... enable row level security가 RLS를 켭니다. 켠 순간부터 정책이 없으면 전부 거부이고, 아래 네 정책이 허용할 일을 하나씩 엽니다.

정책마다 to authenticated를 붙였습니다. 로그인하지 않은 요청이 auth.uid()를 평가할 일 자체를 없애려는 것이고, 정책을 읽는 사람도 어느 역할을 위한 정책인지 바로 압니다.

(select auth.uid())처럼 함수를 select로 감싼 형태를 쓰는 것이 좋습니다. 이렇게 감싸면 Postgres가 행마다 함수를 부르지 않고 질의당 한 번만 계산해 둘 수 있습니다. 행 수가 많을 때 의미가 있고, 결과가 행 값에 따라 달라지는 함수에는 쓸 수 없습니다.

명령마다 필요한 절이 다릅니다. 조회와 삭제는 기존 행을 걸러 내는 using이 필요하고, 삽입은 새 행을 검사하는 with check가 필요하며, 수정은 둘 다 필요합니다.

명령using (기존 행 필터)with check (새 행 검사)조건에 맞지 않을 때
select필요쓰지 않음0행
insert쓰지 않음필요오류
update필요필요대상이 아닌 행은 0행, 새 값이 어긋나면 오류
delete필요쓰지 않음0행

수정 정책에서 with check를 명시한 이유는 이렇습니다. 내 메모의 user_id를 다른 사람 ID로 바꿔서 메모를 떠넘기는 요청을 막기 위해서입니다. Postgres는 수정 정책에 using만 있으면 그 식을 with check에도 똑같이 쓰지만, 의도가 눈에 보이도록 적어 두었습니다. 또 수정과 삭제는 대개 읽을 수 있는 행이어야 하므로(where나 returning이 열을 읽습니다) select 정책도 함께 둡니다.

마지막 grant 문은 authenticated에게만 네 가지 작업을 허용합니다. anon에게는 아무것도 주지 않았으므로, 로그인하지 않은 요청은 정책까지 가지 못하고 권한 단계에서 막힙니다. 정책이 막는 것과 권한이 막는 것은 오류 모양이 다르니 뒤에서 구분해서 확인합니다.

이 마이그레이션을 로컬 데이터베이스에 반영하려면 supabase db reset을 씁니다. 이 명령은 로컬 데이터베이스를 지우고 마이그레이션을 처음부터 다시 적용합니다. 로컬 컨테이너에만 영향을 주지만, 거기에 직접 넣어 둔 데이터가 있다면 사라집니다.

bash
supabase db reset

허용과 거부가 실제로 어떻게 돌아오는가

테스트를 쓰기 전에 요청 종류별로 무엇이 돌아오는지 봐 두면 단언을 어떻게 써야 할지 보입니다. 같은 마이그레이션을 적용한 로컬 데이터베이스에서 authenticated 역할과 사용자 A의 ID로 요청을 흉내 내어 얻은 결과입니다.

요청돌아온 결과종류
A가 메모 전체 조회A의 메모 1행(B의 메모는 없음)필터링
A가 B의 메모를 수정UPDATE 0오류 없이 0행
A가 B의 메모를 삭제DELETE 0오류 없이 0행
A가 user_id를 B로 해서 삽입new row violates row-level security policy for table "notes"오류(42501)
로그인하지 않은 요청이 조회permission denied for table notes권한 오류(42501)

주의할 점은 같은 42501 코드라도 원인이 둘이라는 것입니다. 정책의 with check를 어긴 경우와 권한 자체가 없는 경우입니다. 오류 문구를 같이 검사하면 둘을 구분할 수 있습니다. 반대로 다른 사람의 행을 고치려는 요청은 오류가 아니라 0행이 돌아옵니다. “오류가 없었으니 성공”이라고 읽으면 안 되는 이유입니다.

pgTAP로 검증 자동화하기

pgTAP는 Postgres 안에서 도는 단위 테스트 도구이고, Supabase CLI에 포함되어 있습니다. 테스트 파일은 supabase/tests/ 아래의 .sql 파일이며, supabase test new로 뼈대를 만들 수 있습니다.

bash
supabase test new notes_rls

실행하면 supabase/tests/notes_rls_test.sql이 만들어집니다. 이 파일 내용을 아래로 바꿉니다.

sql
begin;
select plan(14);

-- 테스트 사용자 A, B(가짜 계정). postgres 역할은 RLS를 우회하므로 준비 데이터를 넣을 수 있다.
insert into auth.users (id, email) values
  ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'a@example.test'),
  ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'b@example.test');

insert into public.notes (user_id, content) values
  ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'A의 메모'),
  ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'B의 메모');

select is(
  (select relrowsecurity from pg_class where oid = 'public.notes'::regclass),
  true,
  'notes 테이블에서 RLS가 켜져 있다'
);

select ok(
  not has_table_privilege('anon', 'public.notes', 'select,insert,update,delete'),
  'anon에게는 notes 권한이 없다'
);

-- 사용자 A로 전환
set local role authenticated;
set local request.jwt.claim.sub = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';

select results_eq(
  $$ select content from public.notes order by id $$,
  array['A의 메모'],
  'A는 자신의 메모만 조회한다'
);

select is_empty(
  $$ select 1 from public.notes where content = 'B의 메모' $$,
  'A가 B의 메모를 조회하면 0건이다(오류 없음)'
);

select results_eq(
  $$ insert into public.notes (content) values ('A가 새로 쓴 메모') returning content $$,
  array['A가 새로 쓴 메모'],
  'A는 자신의 메모를 만든다(user_id 기본값 auth.uid())'
);

select throws_ok(
  $$ insert into public.notes (user_id, content) values ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'B인 척 쓴 메모') $$,
  '42501',
  'new row violates row-level security policy for table "notes"',
  'A가 user_id를 B로 지정해 만들면 거부된다'
);

select is_empty(
  $$ update public.notes set content = '바꿈' where content = 'B의 메모' returning id $$,
  'A가 B의 메모를 수정해도 바뀐 행이 없다(오류 없음)'
);

select is_empty(
  $$ delete from public.notes where content = 'B의 메모' returning id $$,
  'A가 B의 메모를 삭제해도 지워진 행이 없다(오류 없음)'
);

select results_eq(
  $$ update public.notes set content = 'A의 메모(수정)' where content = 'A의 메모' returning content $$,
  array['A의 메모(수정)'],
  'A는 자신의 메모를 수정한다'
);

select throws_ok(
  $$ update public.notes set user_id = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb' where content = 'A의 메모(수정)' $$,
  '42501',
  'new row violates row-level security policy for table "notes"',
  'A가 자신의 메모의 주인을 B로 바꾸면 거부된다'
);

select results_eq(
  $$ delete from public.notes where content = 'A가 새로 쓴 메모' returning content $$,
  array['A가 새로 쓴 메모'],
  'A는 자신의 메모를 삭제한다'
);

-- 비로그인(anon)으로 전환
reset role;
set local role anon;

select throws_ok(
  $$ select count(*) from public.notes $$,
  '42501',
  'permission denied for table notes',
  '비로그인 사용자는 조회 자체가 권한 오류다(GRANT가 없다)'
);

select throws_ok(
  $$ insert into public.notes (user_id, content) values ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '익명이 쓴 메모') $$,
  '42501',
  'permission denied for table notes',
  '비로그인 사용자의 쓰기도 권한 오류다'
);

-- postgres로 돌아와 원본을 확인한다. 거부된 요청이 B의 메모를 건드리지 않았어야 한다.
reset role;
select results_eq(
  $$ select content from public.notes order by content $$,
  array['A의 메모(수정)', 'B의 메모'],
  '원본 데이터: B의 메모는 처음 그대로다'
);

select * from finish();
rollback;

이 파일이 하는 일을 순서대로 보면 이렇습니다.

begin으로 시작해 rollback으로 끝납니다. 테스트가 넣은 가짜 사용자와 메모는 어디에도 남지 않습니다. plan(14)는 단언이 14개라는 선언이고, 실제로 실행된 개수와 다르면 테스트가 실패합니다. 단언을 지우거나 중간에 오류로 멈추는 경우를 잡아 줍니다.

준비 데이터는 postgres 역할로 넣습니다. 이 역할은 RLS를 우회하므로 A와 B의 메모를 마음대로 넣을 수 있습니다. 그다음 set local role authenticated와 set local request.jwt.claim.sub = '...'로 로그인한 A를 흉내 냅니다. auth.uid()는 이 설정값을 읽습니다. set local은 트랜잭션이 끝나면 사라지는 설정이라 다음 테스트에 새지 않습니다. 같은 사용자를 request.jwt.claims에 JSON으로 넣는 방식도 로컬에서 같은 결과를 냈습니다.

단언은 거부되는 방식에 맞춰 골랐습니다.

  • 오류가 나야 하는 경우(권한 없음, with check 위반)는 throws_ok로 오류 코드와 문구를 확인합니다.
  • 0행이 나와야 하는 경우(남의 메모 조회, 수정, 삭제)는 is_empty로 확인합니다. 수정과 삭제에는 returning을 붙여, 실제로 바뀐 행이 없음을 검사합니다.
  • 허용되어야 하는 쓰기는 lives_ok가 아니라 returning으로 돌려받은 값을 results_eq로 확인합니다. lives_ok는 아무 행도 바뀌지 않았어도 통과하기 때문입니다.
  • 마지막에는 다시 postgres로 돌아와 원본을 봅니다. 거부된 요청이 B의 메모를 건드리지 않았다는 증거를 따로 남기는 것입니다.

실행합니다.

bash
supabase test db

이 명령은 로컬 데이터베이스에 접속해 pg_prove 컨테이너로 테스트를 돌립니다. 처음에는 pg_prove 이미지를 한 번 내려받고, 성공하면 notes_rls_test.sql .. ok, All tests successful., Result: PASS가 나옵니다. 14개 단언이 모두 통과한 결과입니다. 실패하면 어느 단언이 왜 실패했는지가 Failed test N: "..."로 출력되고 마지막에 Result: FAIL이 나옵니다.

아래는 이 글의 예제로 supabase test db를 실행한 터미널 화면입니다.

터미널에서 supabase test db를 실행한 화면으로, notes_rls_test.sql이 ok이고 Tests=14, Result: PASS가 보인다

supabase test db는 기본이 로컬 데이터베이스입니다. 연결된 원격 프로젝트에 테스트를 돌리는 --linked 옵션도 있지만, 이 글의 절차에서는 쓰지 않습니다. 테스트가 쓰는 가짜 사용자 입력은 rollback으로 지워지긴 해도, 운영 데이터베이스에 연결한 채 실험할 이유는 없습니다.

정책을 일부러 깨뜨려 테스트가 잡는지 보기

테스트가 통과했다는 사실은 테스트가 쓸모 있다는 증거가 아닙니다. 정책을 일부러 틀리게 바꿨을 때 실패해야 쓸모가 있습니다. 로컬 데이터베이스에서 세 가지를 바꿔 보고, 매번 supabase db reset으로 되돌린 뒤 다음 실험을 했습니다. 변경은 psql 'postgresql://postgres:postgres@127.0.0.1:54322/postgres'처럼 로컬 데이터베이스에 접속해 적용합니다(psql이 설치되어 있어야 합니다. 없다면 docker exec supabase_db_notes-rls-lab psql -U postgres -c '문장'처럼 데이터베이스 컨테이너 안의 psql을 쓸 수 있고, 컨테이너 이름은 docker ps로 확인합니다). 이 주소는 로컬 스택의 기본값이며 supabase/config.toml의 [db] 항목에 포트가 있습니다. 실험은 한 번에 하나만 적용합니다. 매번 supabase db reset으로 초기 상태로 돌린 뒤, 변경 하나를 적용하고, supabase test db를 돌려 결과를 보고, 다음 실험 전에 다시 초기화합니다.

실험 1. 조회 정책을 모든 행에 열기

sql
drop policy "notes_select_own" on public.notes;
create policy "notes_select_own" on public.notes
  for select to authenticated using (true);

실험 2. anon에게 권한 주기 (실험 1을 되돌린 뒤 실행)

sql
grant select, insert on public.notes to anon;

실험 3. authenticated 권한 모두 회수 (실험 2를 되돌린 뒤 실행)

sql
revoke all on public.notes from authenticated;
변경실패한 단언읽는 법
조회 정책을 using (true)로 넓힘3번, 4번A가 B의 메모를 읽게 되어 A의 목록에 두 번째 행이 생김
anon에게 select, insert 권한을 줌2번, 12번, 13번권한 단계가 열리자 조회는 오류 없이 통과하고, 삽입은 permission denied가 아니라 RLS 위반 오류로 바뀜
authenticated의 권한을 모두 회수첫 조회에서 중단permission denied for table notes와 GRANT SELECT 힌트가 나오고, 14개 중 2개만 실행되어 Bad plan이 보고됨

실험 1을 적용하고 supabase test db를 돌린 터미널 화면입니다. 3번과 4번 단언이 실패하고 Result: FAIL이 나옵니다. 3번의 have: ("B의 메모")는 A의 목록에 B의 메모가 섞여 들어왔다는 뜻입니다.

정책을 using (true)로 바꾼 뒤 터미널에서 supabase test db를 실행한 화면으로, 3번과 4번 테스트가 실패하고 A의 목록에 B의 메모가 나타났으며 Result: FAIL이 보인다

첫 번째가 가장 위험한 실수입니다. 정책 하나를 using (true)로 쓰면 오류도 없고 화면도 멀쩡한데 모든 사용자의 모든 행이 열립니다. 이 변경은 2개 단언이 잡았습니다. 두 번째는 정책이 아니라 권한이 열린 경우인데, 정책이 있어서 조회는 0행이 되고 삽입은 RLS 위반으로 막히지만, 권한을 열어 둔 것 자체가 방어선 하나를 줄인 것입니다. 단언 2번(has_table_privilege)이 이런 변화를 직접 알려 줍니다.

마지막 실험까지 끝났다면 supabase db reset으로 되돌리고 supabase test db를 다시 돌려 Result: PASS가 돌아오는지 확인합니다.

푸른 조명이 켜진 서버실의 랙 줄과 초록색 상태 표시등

실습 끝내고 정리하기

실습이 끝나면 실습 폴더 안에서 supabase stop --no-backup으로 컨테이너를 멈춥니다. --no-backup은 로컬 데이터베이스 볼륨까지 지우므로, 실습 데이터를 남기고 싶다면 --no-backup 없이 supabase stop만 실행합니다. 받아 둔 이미지는 docker images로 확인하고 더 쓰지 않을 때 docker rmi로 지우며, 다른 프로젝트가 쓰는 이미지가 아닌지 먼저 봅니다.

테스트가 통과해도 남는 위험

이 테스트는 set local role로 역할을 바꿔 Postgres 안에서만 확인합니다. 아래 위험은 이 방식으로는 보이지 않거나, 정책 밖의 문제입니다.

RLS를 우회하는 키. secret key(service_role)는 RLS를 무시합니다. 이 키로 성공한 요청은 일반 사용자의 보안을 증명하지 못합니다. 브라우저나 앱 번들에 넣으면 안 되고 서버에서만 써야 합니다. 요청에 사용자 토큰이 함께 실릴 때의 동작은 문서마다 표현이 다르므로, 이 동작에 기대어 키를 노출하는 것은 안전한 설계가 아닙니다.

뷰. 뷰는 보통 postgres 사용자로 만들어져서 RLS를 우회합니다. 보호된 테이블 위의 뷰는 정책이 막으려던 행을 그대로 내보낼 수 있습니다. Postgres 15 이상에서는 security_invoker = true로 뷰가 호출한 사용자의 권한으로 실행되게 할 수 있습니다.

security definer 함수. 함수를 만든 사람의 권한으로 실행되므로 RLS를 우회할 수 있습니다. 노출된 스키마에 두지 않고, search_path를 비워 고정하고, execute 권한을 필요한 역할에만 주는 식으로 따로 점검해야 합니다.

지나치게 넓은 정책. to anon using (true)는 로그인하지 않은 모든 방문자에게 읽기를 허용하는 정책입니다. 공개하려는 데이터에만 써야 합니다. 정책을 쓸 때 대상 역할을 항상 to로 명시하면 이런 실수를 눈으로 찾기 쉽습니다.

테이블 소유자. 테이블 소유자는 보통 RLS를 적용받지 않습니다. Supabase 테이블 소유자 postgres는 bypassrls 속성(RLS를 건너뛰는 권한)이 있어서 force row level security를 켜도 우회합니다. 소유자 역할로 접속하는 서버 코드가 있다면 그 코드의 접근 범위를 따로 점검해야 합니다.

가짜 사용자와 실제 토큰의 차이. 이 테스트는 auth.uid()가 읽는 값을 직접 설정합니다. 실제 로그인으로 받은 토큰이 supabase-js와 API 게이트웨이를 거쳐 정책에 도달하는 경로까지는 검증하지 않습니다. 앱 코드 수준에서 A와 B 계정으로 한 번씩 접속해 보는 확인은 따로 남습니다.

운영 프로젝트에 적용하기 전에

정책 변경은 기존 기능을 멈출 수 있습니다. 권한을 회수하거나 정책을 추가하면 지금 잘 되던 요청이 42501이나 빈 결과로 바뀝니다. 적용 전에 다음을 확인하는 것이 좋습니다.

  1. 변경을 SQL 마이그레이션 파일로 남기고 로컬에서 supabase db reset과 supabase test db를 통과시켰는지 봅니다. 대시보드에서 손으로 바꾸면 환경마다 상태가 갈라집니다.
  2. 정상 사용자의 요청이 계속 성공하는지 로컬이나 별도의 테스트 프로젝트에서 앱을 실제로 돌려 봅니다. 허용 테스트만 있고 앱 경로 테스트가 없으면 정책이 너무 막아도 알 수 없습니다.
  3. 운영 프로젝트의 백업과 복구 방법을 확인합니다. 어떤 플랜에서 어떤 백업을 쓸 수 있는지는 요금제마다 다릅니다.
  4. 기본 권한 정책이 내 프로젝트에 어떻게 적용되는지 확인합니다. 기존 프로젝트는 2026년 10월 30일부터 새 테이블에 grant를 직접 주어야 Data API가 닿습니다.
  5. 운영 데이터베이스를 바꾸는 작업은 계정 소유자나 팀의 별도 승인을 받고 진행합니다.

조직과 프로젝트를 어떻게 나누는지는 Supabase Organization과 Project에서 다뤘습니다.

마무리 정리

  • 로그인이 되어 있어도 테이블 단위로 grant와 RLS 정책을 모두 확인합니다. 정책만 있고 권한이 없거나, 권한만 있고 RLS가 꺼져 있으면 의도와 다르게 동작합니다.
  • 정책은 to authenticated처럼 역할을 명시하고, 수정 정책에는 using과 with check를 함께 씁니다.
  • 테스트에서 허용은 returning으로 받은 값을 확인하고, 거부는 오류인지 0행인지를 구분해서 확인합니다. 마지막에는 원본 데이터가 그대로인지 봅니다.
  • 정책을 일부러 using (true)로 넓혀 보고 테스트가 실패하는지 확인하면, 테스트가 실제로 막는 범위를 알 수 있습니다.
  • 운영 프로젝트에는 마이그레이션 파일로, 백업과 승인을 확인한 뒤에 적용합니다. 서버용 secret key는 브라우저에 넣지 않습니다.

출처 및 참고자료

마지막 확인

출처 3개 더 보기출처 접기