
Supabase로 회원가입과 데이터 저장 기능을 구현했다면 RLS라는 용어를 한 번쯤 보셨을 겁니다.
RLS를 활성화하라는 안내에 따라 설정은 했지만, 다음과 같은 의문이 남을 수 있습니다.
USING과 WITH CHECK는 무엇이 다른가?RLS는 강력한 기능이지만 활성화 여부보다 정책의 내용과 테스트가 더 중요합니다.
이번 글에서는 Supabase RLS를 설정할 때 자주 하는 실수 10가지와 확인 방법을 정리해보겠습니다.
이 글은 2026년 6월 21일 기준 Supabase 및 PostgreSQL 공식 문서를 바탕으로 작성했습니다. 설정 화면과 기능은 변경될 수 있으므로 글 하단의 공식 문서도 함께 확인해주세요.
RLS(Row Level Security)는 PostgreSQL에서 사용자별로 접근할 수 있는 행을 제한하는 기능입니다.
예를 들어 bookings 테이블에 여러 사용자의 예약 정보가 함께 저장되어 있더라도, RLS 정책을 이용하면 각 사용자가 자신의 예약만 조회하도록 제한할 수 있습니다.
create policy "Users can view their own bookings"
on public.bookings
for select
to authenticated
using ((select auth.uid()) = user_id);
이 정책은 조회 쿼리에 다음과 같은 조건이 추가되는 것과 비슷하게 동작합니다.
where auth.uid() = user_id
Supabase에서 기본적으로 API에 노출되는 스키마는 public입니다. 브라우저에서 데이터베이스에 직접 접근하는 구조라면 해당 스키마의 테이블에 적절한 RLS 설정이 필요합니다.
다만 RLS는 PostgreSQL의 테이블 권한을 대신하는 기능이 아닙니다. 요청이 허용되려면 기본 테이블 권한과 RLS 정책을 모두 통과해야 합니다.
Supabase Dashboard의 Table Editor로 생성한 테이블은 RLS가 기본적으로 활성화됩니다.
하지만 SQL Editor, Migration 또는 외부 도구를 이용해 만든 테이블은 RLS를 직접 활성화해야 합니다.
alter table public.bookings enable row level security;
특히 AI가 작성한 SQL을 그대로 실행했다면 테이블 생성문만 있고 RLS 활성화 구문은 빠져 있을 수 있습니다.
Supabase Dashboard에서 API에 노출된 테이블을 하나씩 확인합니다.
public 이외에 별도로 노출한 스키마가 있는가Security Advisor에서도 public 스키마의 RLS 비활성화 등 일부 문제를 확인할 수 있습니다.
Table Editor로 만든 테이블이 안전하다는 뜻은 아닙니다. RLS가 켜져 있더라도 정책이 적절한지는 별도로 확인해야 합니다.
RLS를 활성화해도 서비스에 필요한 접근 정책이 자동으로 생성되지는 않습니다.
RLS가 활성화된 테이블에 적용 가능한 정책이 없다면 일반 사용자의 접근에는 기본 거부가 적용됩니다. Supabase 공식 문서도 공개용 키를 사용한 API 요청은 정책이 생성되기 전까지 데이터에 접근할 수 없다고 설명합니다.
하지만 다음과 같은 높은 권한의 요청은 별도로 봐야 합니다.
secret key를 사용하는 서버 요청service_role 키를 사용하는 서버 요청BYPASSRLS 권한을 가진 역할따라서 관리자용 키로 기능이 동작한다고 해서 일반 사용자의 RLS 정책까지 정상이라는 뜻은 아닙니다.
service_role 키를 사용한다.RLS 때문에 요청이 실패한다면 높은 권한의 키로 우회하기 전에 필요한 정책이 무엇인지 먼저 확인해야 합니다.
RLS 정책은 작업별로 나누어 생각해야 합니다.
| 작업 | 주로 사용하는 조건 |
|---|---|
SELECT | USING |
INSERT | WITH CHECK |
UPDATE | USING, WITH CHECK |
DELETE | USING |
예를 들어 자신의 예약만 조회하도록 설정했다고 해서 등록·수정·삭제 정책까지 자동으로 생기지는 않습니다.
create policy "Users can view their own bookings"
on public.bookings
for select
to authenticated
using ((select auth.uid()) = user_id);
create policy "Users can create their own bookings"
on public.bookings
for insert
to authenticated
with check ((select auth.uid()) = user_id);
create policy "Users can update their own bookings"
on public.bookings
for update
to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);
create policy "Users can delete their own bookings"
on public.bookings
for delete
to authenticated
using ((select auth.uid()) = user_id);
Supabase 공식 문서에 따르면 UPDATE가 정상적으로 동작하려면 해당 행에 대한 SELECT 정책도 필요합니다.
위 코드는 사용자가 자신의 데이터만 관리하는 단순한 예시입니다. 실제 서비스에서는 관리자, 팀, 공유 데이터 등 서비스의 권한 구조에 맞게 정책을 설계해야 합니다.
두 조건은 검사하는 대상이 다릅니다.
USING현재 저장되어 있는 행 중 어떤 행에 접근할 수 있는지를 판단합니다.
주로 다음 작업에 사용됩니다.
SELECTUPDATE할 기존 행 선택DELETE할 기존 행 선택WITH CHECK새로 저장되거나 수정된 결과가 정책 조건을 만족하는지 판단합니다.
주로 다음 작업에 사용됩니다.
INSERTUPDATE 후의 새로운 행예를 들어 사용자가 자신의 예약을 다른 사용자의 소유로 변경하지 못하게 하려면 UPDATE 정책에 두 조건을 모두 적용할 수 있습니다.
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id)
USING: 수정하려는 기존 예약이 자신의 것인지 확인WITH CHECK: 수정 후에도 예약 소유자가 자신인지 확인UPDATE 정책에서 WITH CHECK를 생략하면 PostgreSQL은 적용 가능한 USING 조건을 새 행 검사에도 사용할 수 있습니다. 하지만 정책의 의도를 명확히 표현하고 검토하기 위해 두 조건을 구분해서 작성하는 편이 이해하기 쉽습니다.
다음 정책은 간단하지만 적용 범위를 정확히 이해해야 합니다.
create policy "Authenticated users can view bookings"
on public.bookings
for select
to authenticated
using (true);
이 정책은 로그인한 사용자라면 대상 테이블의 모든 행을 조회하도록 허용할 수 있습니다.
로그인 여부만 확인할 뿐, 데이터의 소유자는 확인하지 않기 때문입니다.
사용자별로 데이터를 분리해야 한다면 다음과 같이 사용자 ID를 비교해야 합니다.
using ((select auth.uid()) = user_id);
PostgreSQL의 RLS 정책은 기본적으로 PERMISSIVE 정책이며, 같은 작업에 적용되는 여러 기본 정책은 OR 조건으로 결합됩니다.
예를 들어 다음 두 정책이 함께 있다면:
using (true) 정책두 번째 정책 때문에 첫 번째 정책의 제한이 사실상 넓어질 수 있습니다.
정책 하나만 확인하지 말고 같은 테이블과 작업에 적용되는 모든 정책을 함께 확인해야 합니다.
자신의 계정으로 데이터가 정상적으로 보인다는 사실만으로는 권한이 안전한지 판단할 수 없습니다.
RLS 테스트에는 최소한 다음 상태가 필요합니다.
user_id로 데이터를 등록할 수 있는지 확인합니다.화면에서 버튼을 숨기는 것만으로는 권한 테스트가 되지 않습니다. 자신의 프로젝트에서 실제 Supabase 클라이언트 요청을 보내 결과를 확인해야 합니다.
반복적으로 점검해야 하는 서비스라면 Supabase CLI와 테스트 도구를 이용해 RLS 정책 테스트를 자동화할 수도 있습니다.
Supabase Auth의 사용자 정보에는 다음과 같은 메타데이터가 있습니다.
raw_user_meta_dataraw_app_meta_data두 값은 이름이 비슷하지만 권한 관리에서는 중요한 차이가 있습니다.
raw_user_meta_data인증된 사용자가 사용자 정보 업데이트 기능을 통해 변경할 수 있습니다.
따라서 다음과 같은 중요한 권한을 저장하고 신뢰하면 안 됩니다.
{
"role": "admin"
}
사용자가 수정할 수 있는 값을 기준으로 관리자 권한을 판단하면 권한 상승 문제가 발생할 수 있습니다.
raw_app_meta_data일반 사용자가 직접 수정할 수 없으므로 권한 정보에 사용할 수 있습니다.
다만 메타데이터를 JWT에서 읽는 경우 변경된 값이 기존 토큰에 즉시 반영된다고 가정해서는 안 됩니다. 기존 JWT는 갱신되기 전까지 이전 정보를 포함할 수 있습니다.
서비스 구조에 따라 다음과 같은 방법을 검토할 수 있습니다.
app_metadata사용자 프로필 정보와 서비스 권한 정보는 분리해서 생각하는 것이 좋습니다.
기존 service_role 키와 새로운 secret key는 높은 권한을 가진 서버용 키입니다.
이 키를 이용한 요청은 service_role 역할을 사용하며 RLS를 우회할 수 있습니다. 따라서 해당 키로 데이터를 조회해보고 RLS가 정상이라고 판단하면 안 됩니다.
service_role KeyRLS 점검은 실제 사용자가 사용하는 공개용 Key와 사용자 세션으로 진행해야 합니다.
service_role 또는 Secret key는 다음 위치에 포함하면 안 됩니다.
Supabase Storage의 접근 권한은 일반 서비스 테이블의 정책과 별도로 관리됩니다.
Storage 객체 접근 정책은 주로 storage.objects 테이블에 설정합니다.
create policy "Users can view their own files"
on storage.objects
for select
to authenticated
using ((select auth.uid())::text = owner_id);
실제 정책은 버킷, 폴더 구조, 객체 소유 방식에 맞게 작성해야 합니다.
Private 버킷은 파일 다운로드를 포함한 작업에 접근 제어가 적용됩니다. 인증된 다운로드 또는 제한된 시간 동안 유효한 Signed URL을 사용할 수 있습니다.
Public 버킷은 파일 조회와 제공 과정에서 접근 제어를 우회합니다. 파일 URL을 가진 사용자가 파일에 접근할 수 있습니다.
다만 Public 버킷이라고 해서 업로드, 삭제, 이동, 복사까지 모두 공개되는 것은 아닙니다. 이러한 작업에는 여전히 접근 정책이 적용됩니다.
또한 파일을 upsert 방식으로 덮어쓰려면 INSERT뿐 아니라 SELECT, UPDATE 권한도 필요할 수 있습니다.
Supabase는 요청 상태에 따라 PostgreSQL 역할을 구분합니다.
anonauthenticated로그인 상태에서만 테스트하면 anon 역할에 잘못 허용된 정책을 발견하지 못할 수 있습니다.
Supabase Auth의 signInAnonymously()로 생성한 익명 사용자는 로그인하지 않은 anon 역할과 다릅니다.
익명 로그인 사용자도 사용자 ID와 JWT를 가지며 데이터베이스에서는 authenticated 역할을 사용합니다.
익명 사용자와 일반 가입자를 구분해야 한다면 JWT의 is_anonymous 값을 확인하는 정책이 필요합니다.
인증 정보가 없거나 세션이 만료된 요청에서 auth.uid()는 null을 반환합니다.
정책의 의도를 명확히 하려면 적용 역할을 TO 절로 지정하고, 필요한 경우 인증 상태도 명시적으로 확인하는 것이 좋습니다.
다음 예시는 bookings 테이블에서 사용자가 자신의 예약만 관리하도록 제한하는 기본 형태입니다.
alter table public.bookings enable row level security;
create policy "Users can view their own bookings"
on public.bookings
for select
to authenticated
using ((select auth.uid()) = user_id);
create policy "Users can create their own bookings"
on public.bookings
for insert
to authenticated
with check ((select auth.uid()) = user_id);
create policy "Users can update their own bookings"
on public.bookings
for update
to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);
create policy "Users can delete their own bookings"
on public.bookings
for delete
to authenticated
using ((select auth.uid()) = user_id);
이 코드를 그대로 모든 서비스에 적용해서는 안 됩니다.
다음 조건에 따라 필요한 정책이 달라집니다.
먼저 서비스의 권한 구조를 문장으로 정리한 뒤 정책으로 옮기는 것이 좋습니다.
사용자는 자신의 예약만 조회할 수 있다.
사용자는 자신의 예약만 등록할 수 있다.
사용자는 예약 확정 후에는 내용을 수정할 수 없다.
관리자는 모든 예약을 조회하고 상태를 변경할 수 있다.
SELECT, INSERT, UPDATE, DELETE 정책을 각각 검토했다USING과 WITH CHECK의 목적을 구분했다using (true)처럼 범위가 넓은 정책을 확인했다user_metadata를 중요한 권한 판단에 사용하지 않았다RLS는 활성화 버튼을 누르는 것으로 끝나는 설정이 아닙니다.
다음 질문에 명확하게 답할 수 있어야 합니다.
누가, 어떤 데이터에, 어떤 작업을 할 수 있는가?
정책을 작성한 뒤에는 설정 화면만 확인하지 말고 비로그인 상태와 서로 다른 사용자 계정으로 실제 요청을 테스트해야 합니다.
처음부터 모든 정책을 검토하기 어렵다면 다음 세 가지부터 확인해보세요.
세 질문 중 하나라도 의도와 다른 결과가 나온다면 배포 전에 해당 테이블의 RLS 정책을 다시 확인해야 합니다.