실제로 무엇이 잘못됐나
Supabase는 Postgres 테이블을 자동 생성된 REST API로 노출합니다. 프런트엔드는 anon key(publishable key라고도 함)를 사용해 이 API와 통신합니다. 이 키는 원래 공개를 전제로 한 것입니다. JavaScript 번들에 포함되어 배포되며, 누구나 DevTools에서 읽을 수 있습니다.
모든 테이블에 Row Level Security(RLS)가 켜져 있고 합리적인 정책이 있다면 이 설계는 문제없습니다. RLS는 특정 요청이 어떤 행을 볼 수 있는지 결정하는 장치입니다. TheCyberSecGuru가 지적하듯 Supabase 공식 문서에도 이렇게 적혀 있습니다. ‘RLS가 없으면 anon key를 가진 누구나 모든 데이터에 접근할 수 있습니다.’
그래서 실패는 다음과 같은 순서로 이어집니다:
- AI 도구(또는 지친 사람)가 create table profiles (...)를 생성합니다.
- 아무도 enable row level security를 추가하지 않습니다.
- 테스트에서는 앱이 완벽하게 동작합니다. 버그의 정체가 바로 권한이 지나치게 넓다는 것이기 때문입니다.
- 번들에서 anon key를 복사한 사람은 누구나 그 테이블에 select *를 실행할 수 있습니다.
RLS의 동작 원리에 대한 배경지식이 필요하다면 Supabase Row Level Security 가이드에서 기본을 다룹니다. 아래에서는 테스트와 수정에 집중합니다.
내 앱 점검하는 법(5분)
1. 연구진처럼 외부에서 살펴보세요. 운영 중인 사이트를 열고 DevTools를 연 다음, 로드된 JavaScript에서 supabase.co를 검색합니다. 프로젝트 URL과 그 옆의 anon key가 보일 것입니다. 그런 다음 터미널에서 비공개라고 생각하는 테이블의 행 하나를 요청해 보세요:
curl "https://YOUR-PROJECT.supabase.co/rest/v1/profiles?select=*&limit=1" \
-H "apikey: YOUR_ANON_KEY" \
-H "Authorization: Bearer YOUR_ANON_KEY"응답이 알려 주는 것:
- 빈 배열 [] 또는 권한 오류: 좋습니다. 익명 사용자는 그 테이블을 읽을 수 없습니다.
- 실제 행이 반환됨: 7,403개 사이트와 같은 문제를 안고 있습니다.
사용자 데이터를 담는 모든 테이블에 대해 반복하세요. profiles, users, messages, orders, bookings, leads, waitlist 등입니다. 대기자 명단(waitlist)과 문의 양식 테이블은 잊기 쉽지만 거의 항상 이메일을 담고 있습니다.
번들을 보는 김에 찾은 키가 service_role key가 아니라 anon key인지도 확인하세요. service role은 RLS를 완전히 우회합니다. 프런트엔드에 들어 있다면 유출된 것으로 간주하고 지금 바로 교체하세요. 번들에 있는 것은 모든 방문자와 페이지의 모든 서드파티 스크립트가 읽을 수 있습니다. Polymarket 사용자들이 겪은 것처럼 말이죠. 게다가 봇들은 이미 유출된 키를 찾아다니고 있습니다. 교체 방법은 다운타임 없이 앱 시크릿을 교체하는 방법 글에서, 번들에 흔히 섞여 들어가는 다른 것들은 클라이언트 측 JavaScript에 노출된 API 키에서 다룹니다.
2. 내부에서 점검하세요. Supabase SQL 편집기에서 public 스키마의 모든 테이블과 RLS 활성화 여부를 조회합니다:
select tablename, rowsecurity
from pg_tables
where schemaname = 'public'
order by rowsecurity, tablename;rowsecurity = false인 행은 anon key로 읽을 수 있습니다(쓰기도 가능할 수 있습니다). Supabase 대시보드의 Security Advisor도 이런 테이블을 표시합니다. 경고를 무시하지 말고 꼼꼼히 읽으세요.
3. ‘RLS는 켜져 있지만 결국 열려 있는’ 상태를 찾으세요. RLS를 활성화했다고 끝이 아닙니다. 정책 목록을 조회합니다:
select tablename, policyname, roles, cmd, qual
from pg_policies
where schemaname = 'public';anon 또는 public에 적용되는 select 정책에 qual = true가 있는지 주의하세요. 이는 ‘누구나 모든 것을 읽을 수 있다’는 정책으로, RLS가 없는 것과 같습니다. AI 도구는 오류를 없애려고 바로 이런 정책을 생성하기도 합니다.
고치는 방법
모든 테이블에서 RLS를 켭니다:
alter table public.profiles enable row level security;
alter table public.messages enable row level security;
-- repeat for every table in publicRLS가 켜져 있고 정책이 없으면 테이블은 잠깁니다. anon key로는 아무것도 반환되지 않습니다. 이것이 안전한 기본값입니다. 이제 실제로 필요한 접근만 추가하세요.
범위가 좁은 정책을 작성하세요. 전형적인 ‘사용자는 자기 행만 보고 수정할 수 있다’ 설정은 다음과 같습니다:
create policy "Read own profile"
on public.profiles for select
to authenticated
using ((select auth.uid()) = user_id);
create policy "Update own profile"
on public.profiles for update
to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);유의할 점:
- 데이터가 정말로 공개 정보(블로그 글, 상품 목록 등)가 아니라면 public이 아니라 authenticated로 범위를 제한하세요.
- 읽기와 쓰기를 분리하세요. 공개적으로 읽어도 안전한 테이블이라도 insert는 엄격히 막아야 할 수 있습니다.
- 공개 테이블은 열 단위로도 새어 나갑니다. 표시 이름 때문에 profiles 테이블을 공개한다면, 같은 테이블의 email이나 phone 열도 공개됩니다. 비공개 필드는 별도 테이블로 옮기거나 안전한 열만 담은 뷰를 노출하세요.
- 뷰는 RLS를 우회할 수 있습니다. Postgres에서 뷰는 기본적으로 소유자의 권한으로 실행됩니다. 호출자의 RLS가 적용되도록 security_invoker = true로 뷰를 만드세요.
- Storage는 별개입니다. 공개 버킷은 URL을 가진 누구에게나 파일을 제공합니다. 업로드된 신분증, 청구서, 비공개 사용자의 아바타는 스토리지 정책이 있는 비공개 버킷에 두어야 합니다.
정리 작업이 아니라 기본값으로 만드세요. 연구진의 표본은 10개월간 새로 만들어진 앱에 걸쳐 있으며, 이는 새 테이블이 생길 때마다 이 문제가 반복된다는 뜻입니다. 잊어버리기 어렵게 만드세요:
- AI 도구의 프로젝트 지침에 규칙을 추가하세요: public의 모든 create table 뒤에는 반드시 enable row level security와 명시적인 정책이 따라와야 합니다.
- 병합하기 전에, 생성된 마이그레이션에 짝이 되는 enable row level security 없이 create table만 있는 곳이 없는지 검토하세요.
- 출시 때만이 아니라 스키마를 변경할 때마다 외부 점검을 다시 실행하세요.
Supabase 대신 Firebase를 쓴다면, 같은 종류의 실수가 열린 보안 규칙의 형태로 존재합니다. Firebase Security Rules: 데이터베이스를 열어 두지 마세요를 참고하세요.
노출된 데이터를 발견했다면
정책을 고치면 앞으로의 읽기는 막을 수 있지만, 이미 일어난 읽기를 되돌릴 수는 없습니다. 무엇이 얼마 동안 접근 가능했는지 스스로에게 솔직해지세요. Supabase API 로그에서 낯선 IP의 비정상적인 대량 읽기가 있었는지 확인하고, 읽을 수 있는 테이블에 저장돼 있던 시크릿은 모두 교체하고, 이메일, 신분증, 결제 정보 같은 개인 데이터가 노출됐다면 신고 의무를 확인하세요. EU라면 GDPR의 침해 통지 규정이 적용될 가능성이 높습니다.
빠른 체크리스트
완료라고 하기 전에 다음을 확인하세요:
- public의 모든 테이블이 rowsecurity = true입니다.
- 비공개 데이터를 담은 테이블에 using (true)를 쓰는 select 정책이 없습니다.
- 정책은 authenticated로 범위가 제한되고 auth.uid()에 연결되어 있습니다.
- 비공개 열이 공개적으로 읽을 수 있는 테이블에 있지 않습니다.
- 뷰는 security_invoker = true를 사용합니다.
- 프런트엔드에는 anon key만 있고, service_role은 절대 없습니다.
- 비공개 파일은 비공개 스토리지 버킷에 있습니다.
- 스키마를 변경할 때마다 외부에서 다시 테스트합니다.
외부에서 내 앱 점검하기
AI로 만든 앱을 위한 보안 체크리스트에서 출시 전에 확인할 만한 다른 항목들을 다룹니다. 이번 연구에서 불편한 지점은 방법이 너무나 평범했다는 것입니다. 공개 키로 읽기 전용 요청을 보내면 데이터가 돌아옵니다. 이것이 바로 공격자가 보는 시야이며, 여러분이 정기적으로 확인해야 할 시야입니다.
AppSafe는 운영 중인 앱을 외부에서 스캔합니다. 소유권이 확인된 전체 스캔에는 열린 Supabase 및 Firebase 데이터베이스에 대한 읽기 전용 점검과 JavaScript 번들에 노출된 API 키 탐지가 포함되며, 예약 스캔을 사용하면 배포 후 무언가 바뀌었을 때 알림을 받을 수 있습니다. 리포트가 어떻게 생겼는지 궁금하신가요? 샘플 리포트를 확인해 보세요.
지금 무료로 앱을 스캔하고, 다른 누군가보다 먼저 낯선 사람에게 무엇이 보이는지 확인하세요.
