RLS를 끈 Supabase는 공개 데이터베이스다
2025년에 AI 코딩 도구로 만든 170개 이상의 앱에서 사용자 데이터가 통째로 노출된 사건이 있었다. 공통점은 하나, Supabase RLS(Row Level Security)가 꺼져 있었다.
Supabase anon key는 클라이언트에 노출되는 게 의도된 설계다. 대신 RLS로 "이 key로는 이 데이터만 볼 수 있다"는 규칙을 정의해야 한다. RLS가 없으면 anon key 하나로 테이블 전체를 읽고, 쓰고, 삭제할 수 있다.
왜 위험한지 공격자 관점에서 보면 명확하다. Supabase는 모든 테이블에 대해 https://xxx.supabase.co/rest/v1/테이블명 형태의 REST API를 자동 생성한다. anon key는 브라우저 자바스크립트 번들이나 네트워크 요청 헤더에 그대로 들어 있어, 개발자도구 하나면 누구나 꺼낼 수 있다. RLS가 없다면 공격자는 그 key로 다음 한 줄이면 회원 테이블 전체를 덤프한다.
curl "https://xxx.supabase.co/rest/v1/users?select=*" \
-H "apikey: eyJ...anon..." \
-H "Authorization: Bearer eyJ...anon..."
# RLS가 없으면 → 전체 회원 이메일·전화번호·주소가 JSON으로 쏟아진다
레드팀 관점에서 신규 서비스를 진단할 때 가장 먼저 확인하는 것 중 하나가 이 Supabase 엔드포인트다. 응답이 200에 데이터가 그대로 나오면, 그 서비스는 사실상 로그인 없는 공개 DB를 운영하고 있는 셈이다. UI에 버튼이 없다고 데이터가 안전한 게 아니다, API는 UI를 거치지 않는다.
가장 흔한 실수 3가지
실수 1: RLS를 아예 활성화 안 함
Supabase 대시보드에서 테이블을 만들면 기본값이 RLS 비활성화다. AI가 생성한 코드도 대부분 RLS 설정을 포함하지 않는다. "일단 동작하니까" 넘어간 테이블 하나가 전체 서비스의 구멍이 된다.
-- RLS 활성화 (이것만 해도 일단 잠긴다)
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
RLS를 활성화하면 아무 정책도 없는 상태에서는 아무것도 읽을 수 없다(기본 deny). 이후 필요한 접근만 허용하는 정책을 추가한다. 순서가 중요하다, 먼저 전부 잠그고, 필요한 문만 하나씩 여는 방식이어야 실수가 사고로 이어지지 않는다.
직접 확인하는 법: SQL Editor에서 아래를 실행하면 RLS가 꺼진 테이블이 한눈에 잡힌다. relrowsecurity가 false인 테이블이 방치된 구멍이다.
SELECT relname AS table_name, relrowsecurity AS rls_on
FROM pg_class
WHERE relkind = 'r'
AND relnamespace = 'public'::regnamespace
ORDER BY relrowsecurity; -- false부터 정렬 → 위쪽이 위험 테이블
실수 2: 너무 느슨한 정책
RLS를 켰다고 끝이 아니다. 정책 조건이 헐거우면 켠 의미가 없다. 가장 흔한 패턴이 USING (true), "로그인만 했으면 다 통과"다. 이러면 A 사용자가 B 사용자의 주문 내역을 그대로 조회한다.
-- 위험: 로그인한 모든 사용자가 모든 행 열람 가능
CREATE POLICY "allow_all_authenticated"
ON users FOR SELECT
TO authenticated
USING (true);
-- 안전: 본인 데이터만
CREATE POLICY "own_data_only"
ON users FOR SELECT
USING ((select auth.uid()) = id);
auth.uid()를 (select auth.uid())로 감싼 이유가 있다. 그냥 auth.uid()를 쓰면 행마다 함수가 재평가돼 대규모 테이블에서 쿼리가 느려진다. 서브쿼리로 감싸면 플래너가 값을 한 번만 계산하고 캐싱해, 인덱스도 정상적으로 탄다. 성능과 보안을 동시에 챙기는 표준 패턴이다.
INSERT·UPDATE는 함정이 하나 더 있다. SELECT는 USING으로 검사하지만, 새로 들어오는 행은 WITH CHECK로 검사한다. WITH CHECK를 빼먹으면 사용자가 남의 user_id로 데이터를 심을 수 있다.
-- 안전: 본인 소유로만 쓰기 허용
CREATE POLICY "insert_own_only"
ON orders FOR INSERT
TO authenticated
WITH CHECK ((select auth.uid()) = user_id);
CREATE POLICY "update_own_only"
ON orders FOR UPDATE
TO authenticated
USING ((select auth.uid()) = user_id) -- 어떤 행을 고를 수 있나
WITH CHECK ((select auth.uid()) = user_id); -- 고친 뒤에도 여전히 내 것인가
실수 3: service_role key를 클라이언트에 노출
service_role key는 RLS를 완전히 무시한다. 위에서 정성껏 짠 정책이 이 key 앞에서는 전부 무력화된다. 절대 클라이언트 코드에 넣으면 안 된다. anon key만 클라이언트에 사용하고, service_role은 서버 사이드 전용으로 관리한다.
# .env.local (클라이언트용 — Next.js 등)
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ... # anon key만
# .env (서버용)
SUPABASE_SERVICE_ROLE_KEY=eyJ... # NEXT_PUBLIC_ 붙이지 않기
가장 흔한 유출 경로는 환경변수 접두사 실수다. Next.js에서 NEXT_PUBLIC_가 붙은 변수는 빌드 시 클라이언트 번들에 그대로 박힌다. service_role key에 실수로 이 접두사를 붙이면, 배포하는 순간 관리자 권한 key가 전 세계에 공개된다. Vite 계열은 VITE_, Create React App은 REACT_APP_가 같은 역할을 하니 프레임워크별 접두사 규칙을 반드시 확인한다.
직접 확인하는 법: 배포된 사이트에서 개발자도구 → Sources(또는 Network) 탭을 열고 Ctrl+F로 service_role을 검색한다. Supabase key는 JWT라, anon key를 jwt.io에 붙여넣어 payload의 "role" 값이 anon인지 확인하면 확실하다. service_role이 잡히면 즉시 대시보드에서 key를 rotate(재발급)하고, 유출된 기간의 접근 로그를 점검해야 한다.
RLS 제대로 설정된 것인지 검증하는 법
설정 후에는 반드시 다른 사용자 계정으로 로그인해서 남의 데이터가 보이는지 테스트해야 한다. 정책은 "짰다"가 아니라 "뚫어봤는데 안 뚫린다"까지 가야 검증이다. Supabase 대시보드의 "Auth > Users" 메뉴에서 테스트 계정을 만들고 SQL Editor에서 직접 쿼리해보는 게 가장 확실하다.
-- 다른 유저 ID로 테스트
SET LOCAL role TO authenticated;
SET LOCAL request.jwt.claims TO '{"sub": "other-user-uuid"}';
SELECT * FROM orders; -- 빈 결과가 나와야 정상
세 각도에서 교차 검증하면 빠진 구멍이 드러난다.
- anon 역할로 테스트,
SET LOCAL role TO anon;후 각 테이블을 조회한다. 로그인 없이 보이면 안 되는 데이터가 나오면 정책 누락이다. - 쓰기·수정·삭제까지, SELECT만 잠그고 INSERT/UPDATE/DELETE를 방치하는 경우가 많다. 남의
user_id로 INSERT가 성공하면WITH CHECK가 빠진 것이다. - 실제 REST 엔드포인트로, 위의
curl명령을 anon key로 직접 쏴본다. SQL Editor는 통과해도 PostgREST 경로에서 다르게 동작하는 엣지 케이스를 잡아낸다.
여기에 자주 놓치는 우회로가 하나 더 있다. SECURITY DEFINER로 만든 함수나 뷰는 RLS를 우회할 수 있다. 편의를 위해 만든 뷰 하나가 정책을 통째로 무력화하는 백도어가 되기도 하니, 함수·뷰의 권한 설정도 함께 점검한다.
바이브코딩 앱, 배포 전 30초 점검
Supabase RLS는 "켰다/껐다"의 문제가 아니라 테이블마다, 작업(읽기·쓰기·수정·삭제)마다 정책이 맞는지의 문제다. 테이블이 10개면 점검 포인트는 수십 개로 늘어난다. AI 코딩 도구로 빠르게 만든 서비스일수록 이 점검이 통째로 빠져 있는 경우가 많다.
내 서비스의 Supabase 엔드포인트가 외부에서 어떻게 보이는지, anon key로 데이터가 새는지, 노출된 key와 설정 오류가 없는지, CodeScan에 URL만 넣으면 21개 항목을 자동으로 점검한다. 개발자도구를 열기 전에, 먼저 밖에서 내 서비스가 어떻게 뚫리는지 확인하는 게 순서다.
고객 데이터(CRM·회원 DB)를 다루는 서비스라면 CRM 보안 점검으로 개인정보 노출 위험까지 함께 확인하는 걸 권한다. 보안 사고는 터진 뒤엔 늦다. 배포 전 30초가 유출 대응 몇 주를 막는다.