Supabase RLS 설정에서 자주 하는 실수 10가지

카페인코더·2026년 6월 20일

AI 웹앱 보안 점검

목록 보기
7/11
post-thumbnail

Supabase로 회원가입과 데이터 저장 기능을 구현했다면 RLS라는 용어를 한 번쯤 보셨을 겁니다.

RLS를 활성화하라는 안내에 따라 설정은 했지만, 다음과 같은 의문이 남을 수 있습니다.

  • RLS만 켜면 데이터가 보호되는가?
  • 로그인한 사용자는 자신의 데이터만 볼 수 있는가?
  • USINGWITH CHECK는 무엇이 다른가?
  • RLS가 제대로 동작하는지는 어떻게 확인하는가?

RLS는 강력한 기능이지만 활성화 여부보다 정책의 내용과 테스트가 더 중요합니다.

이번 글에서는 Supabase RLS를 설정할 때 자주 하는 실수 10가지와 확인 방법을 정리해보겠습니다.

이 글은 2026년 6월 21일 기준 Supabase 및 PostgreSQL 공식 문서를 바탕으로 작성했습니다. 설정 화면과 기능은 변경될 수 있으므로 글 하단의 공식 문서도 함께 확인해주세요.


먼저 RLS란 무엇일까?

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 RLS 공식 문서


1. 모든 테이블에 RLS가 켜졌다고 생각한다

Supabase Dashboard의 Table Editor로 생성한 테이블은 RLS가 기본적으로 활성화됩니다.

하지만 SQL Editor, Migration 또는 외부 도구를 이용해 만든 테이블은 RLS를 직접 활성화해야 합니다.

alter table public.bookings enable row level security;

특히 AI가 작성한 SQL을 그대로 실행했다면 테이블 생성문만 있고 RLS 활성화 구문은 빠져 있을 수 있습니다.

이렇게 확인해보세요

Supabase Dashboard에서 API에 노출된 테이블을 하나씩 확인합니다.

  • RLS가 활성화되어 있는가
  • 정책이 필요한 테이블인데 빠져 있지 않은가
  • 테스트용 테이블이 운영 환경에 남아 있지 않은가
  • public 이외에 별도로 노출한 스키마가 있는가

Security Advisor에서도 public 스키마의 RLS 비활성화 등 일부 문제를 확인할 수 있습니다.

Table Editor로 만든 테이블이 안전하다는 뜻은 아닙니다. RLS가 켜져 있더라도 정책이 적절한지는 별도로 확인해야 합니다.


2. RLS를 켜면 필요한 정책도 자동으로 만들어진다고 생각한다

RLS를 활성화해도 서비스에 필요한 접근 정책이 자동으로 생성되지는 않습니다.

RLS가 활성화된 테이블에 적용 가능한 정책이 없다면 일반 사용자의 접근에는 기본 거부가 적용됩니다. Supabase 공식 문서도 공개용 키를 사용한 API 요청은 정책이 생성되기 전까지 데이터에 접근할 수 없다고 설명합니다.

하지만 다음과 같은 높은 권한의 요청은 별도로 봐야 합니다.

  • 새로운 secret key를 사용하는 서버 요청
  • 기존 service_role 키를 사용하는 서버 요청
  • PostgreSQL의 BYPASSRLS 권한을 가진 역할
  • 일반적으로 RLS를 우회하는 테이블 소유자

따라서 관리자용 키로 기능이 동작한다고 해서 일반 사용자의 RLS 정책까지 정상이라는 뜻은 아닙니다.

흔히 발생하는 흐름

  1. RLS를 활성화한다.
  2. 정책을 만들지 않아 기능이 작동하지 않는다.
  3. 원인을 찾지 못하고 service_role 키를 사용한다.
  4. 기능은 동작하지만 RLS를 우회하는 구조가 된다.

RLS 때문에 요청이 실패한다면 높은 권한의 키로 우회하기 전에 필요한 정책이 무엇인지 먼저 확인해야 합니다.

PostgreSQL Row Security 공식 문서


3. SELECT 정책 하나로 모든 작업이 보호된다고 생각한다

RLS 정책은 작업별로 나누어 생각해야 합니다.

작업주로 사용하는 조건
SELECTUSING
INSERTWITH CHECK
UPDATEUSING, WITH CHECK
DELETEUSING

예를 들어 자신의 예약만 조회하도록 설정했다고 해서 등록·수정·삭제 정책까지 자동으로 생기지는 않습니다.

SELECT 정책

create policy "Users can view their own bookings"
on public.bookings
for select
to authenticated
using ((select auth.uid()) = user_id);

INSERT 정책

create policy "Users can create their own bookings"
on public.bookings
for insert
to authenticated
with check ((select auth.uid()) = user_id);

UPDATE 정책

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);

DELETE 정책

create policy "Users can delete their own bookings"
on public.bookings
for delete
to authenticated
using ((select auth.uid()) = user_id);

Supabase 공식 문서에 따르면 UPDATE가 정상적으로 동작하려면 해당 행에 대한 SELECT 정책도 필요합니다.

위 코드는 사용자가 자신의 데이터만 관리하는 단순한 예시입니다. 실제 서비스에서는 관리자, 팀, 공유 데이터 등 서비스의 권한 구조에 맞게 정책을 설계해야 합니다.


4. USING과 WITH CHECK를 구분하지 않는다

두 조건은 검사하는 대상이 다릅니다.

USING

현재 저장되어 있는 행 중 어떤 행에 접근할 수 있는지를 판단합니다.

주로 다음 작업에 사용됩니다.

  • SELECT
  • UPDATE할 기존 행 선택
  • DELETE할 기존 행 선택

WITH CHECK

새로 저장되거나 수정된 결과가 정책 조건을 만족하는지 판단합니다.

주로 다음 작업에 사용됩니다.

  • INSERT
  • UPDATE 후의 새로운 행

예를 들어 사용자가 자신의 예약을 다른 사용자의 소유로 변경하지 못하게 하려면 UPDATE 정책에 두 조건을 모두 적용할 수 있습니다.

using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id)
  • USING: 수정하려는 기존 예약이 자신의 것인지 확인
  • WITH CHECK: 수정 후에도 예약 소유자가 자신인지 확인

UPDATE 정책에서 WITH CHECK를 생략하면 PostgreSQL은 적용 가능한 USING 조건을 새 행 검사에도 사용할 수 있습니다. 하지만 정책의 의도를 명확히 표현하고 검토하기 위해 두 조건을 구분해서 작성하는 편이 이해하기 쉽습니다.


5. 로그인 사용자에게 모든 데이터를 허용한다

다음 정책은 간단하지만 적용 범위를 정확히 이해해야 합니다.

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) 정책

두 번째 정책 때문에 첫 번째 정책의 제한이 사실상 넓어질 수 있습니다.

정책 하나만 확인하지 말고 같은 테이블과 작업에 적용되는 모든 정책을 함께 확인해야 합니다.


6. 계정 하나로만 권한을 테스트한다

자신의 계정으로 데이터가 정상적으로 보인다는 사실만으로는 권한이 안전한지 판단할 수 없습니다.

RLS 테스트에는 최소한 다음 상태가 필요합니다.

  • 로그아웃 상태
  • 사용자 A
  • 사용자 B
  • 필요한 경우 관리자 역할

사용자 간 권한 테스트

  1. 사용자 A로 예약 데이터를 생성합니다.
  2. 사용자 B로 로그인합니다.
  3. B가 A의 예약을 조회할 수 있는지 확인합니다.
  4. B가 A의 예약을 수정할 수 있는지 확인합니다.
  5. B가 A의 예약을 삭제할 수 있는지 확인합니다.
  6. B가 A의 user_id로 데이터를 등록할 수 있는지 확인합니다.

화면에서 버튼을 숨기는 것만으로는 권한 테스트가 되지 않습니다. 자신의 프로젝트에서 실제 Supabase 클라이언트 요청을 보내 결과를 확인해야 합니다.

반복적으로 점검해야 하는 서비스라면 Supabase CLI와 테스트 도구를 이용해 RLS 정책 테스트를 자동화할 수도 있습니다.

Supabase 데이터베이스 테스트 공식 문서


7. user_metadata로 중요한 권한을 판단한다

Supabase Auth의 사용자 정보에는 다음과 같은 메타데이터가 있습니다.

  • raw_user_meta_data
  • raw_app_meta_data

두 값은 이름이 비슷하지만 권한 관리에서는 중요한 차이가 있습니다.

raw_user_meta_data

인증된 사용자가 사용자 정보 업데이트 기능을 통해 변경할 수 있습니다.

따라서 다음과 같은 중요한 권한을 저장하고 신뢰하면 안 됩니다.

{
  "role": "admin"
}

사용자가 수정할 수 있는 값을 기준으로 관리자 권한을 판단하면 권한 상승 문제가 발생할 수 있습니다.

raw_app_meta_data

일반 사용자가 직접 수정할 수 없으므로 권한 정보에 사용할 수 있습니다.

다만 메타데이터를 JWT에서 읽는 경우 변경된 값이 기존 토큰에 즉시 반영된다고 가정해서는 안 됩니다. 기존 JWT는 갱신되기 전까지 이전 정보를 포함할 수 있습니다.

서비스 구조에 따라 다음과 같은 방법을 검토할 수 있습니다.

  • 서버에서만 관리하는 app_metadata
  • 별도의 역할·권한 테이블
  • 관리자 서버를 통한 권한 변경
  • 권한 변경 후 세션과 토큰 갱신 고려

사용자 프로필 정보와 서비스 권한 정보는 분리해서 생각하는 것이 좋습니다.


8. service_role로 RLS가 동작하는지 테스트한다

기존 service_role 키와 새로운 secret key는 높은 권한을 가진 서버용 키입니다.

이 키를 이용한 요청은 service_role 역할을 사용하며 RLS를 우회할 수 있습니다. 따라서 해당 키로 데이터를 조회해보고 RLS가 정상이라고 판단하면 안 됩니다.

올바른 테스트 조건

  • 비로그인 요청: 공개용 Key, 사용자 JWT 없음
  • 사용자 요청: 공개용 Key와 해당 사용자의 JWT
  • 관리자 서버 요청: 필요한 경우에만 Secret 또는 service_role Key

RLS 점검은 실제 사용자가 사용하는 공개용 Key와 사용자 세션으로 진행해야 합니다.

service_role 또는 Secret key는 다음 위치에 포함하면 안 됩니다.

  • 브라우저 JavaScript
  • 모바일 앱 코드
  • 공개 저장소
  • URL과 쿼리 파라미터
  • 사용자에게 배포되는 실행 파일

Supabase API Key 공식 문서


9. 일반 테이블의 RLS가 Storage 파일도 보호한다고 생각한다

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 버킷

Private 버킷은 파일 다운로드를 포함한 작업에 접근 제어가 적용됩니다. 인증된 다운로드 또는 제한된 시간 동안 유효한 Signed URL을 사용할 수 있습니다.

Public 버킷

Public 버킷은 파일 조회와 제공 과정에서 접근 제어를 우회합니다. 파일 URL을 가진 사용자가 파일에 접근할 수 있습니다.

다만 Public 버킷이라고 해서 업로드, 삭제, 이동, 복사까지 모두 공개되는 것은 아닙니다. 이러한 작업에는 여전히 접근 정책이 적용됩니다.

또한 파일을 upsert 방식으로 덮어쓰려면 INSERT뿐 아니라 SELECT, UPDATE 권한도 필요할 수 있습니다.

Supabase Storage 접근 제어 공식 문서

Supabase Storage 버킷 공식 문서


10. 로그인 상태에서만 테스트한다

Supabase는 요청 상태에 따라 PostgreSQL 역할을 구분합니다.

  • 로그인하지 않은 요청: anon
  • 로그인한 요청: authenticated

로그인 상태에서만 테스트하면 anon 역할에 잘못 허용된 정책을 발견하지 못할 수 있습니다.

익명 로그인은 별도로 구분해야 한다

Supabase Auth의 signInAnonymously()로 생성한 익명 사용자는 로그인하지 않은 anon 역할과 다릅니다.

익명 로그인 사용자도 사용자 ID와 JWT를 가지며 데이터베이스에서는 authenticated 역할을 사용합니다.

익명 사용자와 일반 가입자를 구분해야 한다면 JWT의 is_anonymous 값을 확인하는 정책이 필요합니다.

다음 상태를 나누어 테스트하세요

  1. 로그인하지 않은 방문자
  2. 익명 로그인 사용자
  3. 일반 사용자 A
  4. 일반 사용자 B
  5. 관리자 또는 서버 역할
  6. 세션이 만료된 사용자

인증 정보가 없거나 세션이 만료된 요청에서 auth.uid()null을 반환합니다.

정책의 의도를 명확히 하려면 적용 역할을 TO 절로 지정하고, 필요한 경우 인증 상태도 명시적으로 확인하는 것이 좋습니다.

Supabase 익명 로그인 공식 문서


실제 프로젝트에 적용할 정책 예시

다음 예시는 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);

이 코드를 그대로 모든 서비스에 적용해서는 안 됩니다.

다음 조건에 따라 필요한 정책이 달라집니다.

  • 관리자가 모든 예약을 관리하는가
  • 팀원이 데이터를 공유하는가
  • 일부 데이터는 공개되어야 하는가
  • 익명 사용자의 등록을 허용하는가
  • 사용자가 삭제할 수 없는 기록이 있는가

먼저 서비스의 권한 구조를 문장으로 정리한 뒤 정책으로 옮기는 것이 좋습니다.

사용자는 자신의 예약만 조회할 수 있다.
사용자는 자신의 예약만 등록할 수 있다.
사용자는 예약 확정 후에는 내용을 수정할 수 없다.
관리자는 모든 예약을 조회하고 상태를 변경할 수 있다.

배포 전 RLS 체크리스트

  • API에 노출된 모든 테이블의 RLS 상태를 확인했다
  • SQL이나 Migration으로 만든 테이블도 확인했다
  • SELECT, INSERT, UPDATE, DELETE 정책을 각각 검토했다
  • USINGWITH CHECK의 목적을 구분했다
  • using (true)처럼 범위가 넓은 정책을 확인했다
  • 같은 작업에 적용되는 여러 정책을 함께 검토했다
  • user_metadata를 중요한 권한 판단에 사용하지 않았다
  • 공개용 Key와 실제 사용자 JWT로 테스트했다
  • 사용자 A와 B 사이의 교차 접근을 테스트했다
  • 로그아웃 상태의 접근을 테스트했다
  • 익명 로그인을 사용한다면 일반 사용자와 구분했다
  • Storage의 버킷 유형과 별도 정책을 확인했다
  • Security Advisor의 경고를 검토했다
  • 중요한 정책은 자동화된 테스트로 검증할 수 있다

마무리

RLS는 활성화 버튼을 누르는 것으로 끝나는 설정이 아닙니다.

다음 질문에 명확하게 답할 수 있어야 합니다.

누가, 어떤 데이터에, 어떤 작업을 할 수 있는가?

정책을 작성한 뒤에는 설정 화면만 확인하지 말고 비로그인 상태와 서로 다른 사용자 계정으로 실제 요청을 테스트해야 합니다.

처음부터 모든 정책을 검토하기 어렵다면 다음 세 가지부터 확인해보세요.

  1. 사용자 B가 사용자 A의 데이터를 조회할 수 있는가
  2. 사용자 B가 사용자 A의 데이터를 수정하거나 삭제할 수 있는가
  3. 로그인하지 않은 상태에서 사용자 데이터에 접근할 수 있는가

세 질문 중 하나라도 의도와 다른 결과가 나온다면 배포 전에 해당 테이블의 RLS 정책을 다시 확인해야 합니다.


참고한 공식 문서

profile
꾸준히 기록 중 입니다.

0개의 댓글