RLS란 무엇이고, 공개 키가 왜 그것 하나에 달려 있는가
Supabase는 브라우저가 데이터베이스에 직접 말을 거는 구조입니다. 그래서 앱을 만들면 키가 두 개 나옵니다. 하나는 publishable 키(예전 이름 anon)이고, 다른 하나는 secret 키(예전 이름 service_role)입니다. 앞의 것은 브라우저에 실려 나가는 것이 정상이고, 뒤의 것은 절대 브라우저에 두면 안 됩니다.
Supabase 공식 문서는 publishable 키를 두고 웹페이지·모바일 앱·데스크톱 앱·소스코드에 노출해도 안전하다고 적어 두었습니다. 다만 그 안전은 조건부입니다. 키 자체에는 「누가 무엇을 볼 수 있는가」가 들어 있지 않기 때문입니다. 그 판단을 대신 하는 것이 RLS입니다.
RLS는 Row Level Security, 우리말로 행 수준 보안입니다. 테이블의 행 하나하나에 대해 「이 요청자가 이 행을 읽어도 되는가」를 데이터베이스가 직접 판정하게 만드는 기능입니다. Postgres에 원래 있던 기능이고, Supabase는 그것을 켜고 규칙을 쓰는 화면을 제공할 뿐입니다.
그래서 순서가 이렇게 됩니다. 키는 공개된다. 누구나 데이터베이스에 말을 걸 수 있다. RLS가 켜져 있고 정책이 있으면 정책이 허용한 것만 나간다. RLS가 꺼져 있으면 전부 나간다. 중간 단계가 없습니다.
| 키 종류 | 새 이름 | 옛 이름 | 브라우저 노출 | RLS 적용 |
|---|---|---|---|---|
| 공개용 | sb_publishable_... | anon | 정상 (노출 전제) | 받는다 |
| 비공개용 | sb_secret_... | service_role | 금지 | 우회한다 (BYPASSRLS) |
Moltbook은 어떻게 열렸는가

Moltbook은 AI 에이전트끼리 글을 주고받는 소셜 서비스로, 공개되자마자 빠르게 퍼졌습니다. 창업자는 X에 「Moltbook 코드는 한 줄도 안 썼다. 기술 구조에 대한 그림만 있었고 AI가 그것을 현실로 만들었다」고 적었습니다.
보안업체 Wiz는 이 서비스의 배포된 자바스크립트 파일에서 Supabase 주소와 publishable 키를 찾아냈습니다. 여기까지는 사고가 아닙니다. 앞에서 말한 대로 그 키는 보이는 것이 정상입니다.
문제는 그다음이었습니다. 연구진이 그 키로 데이터베이스에 직접 요청을 보내자 권한 오류 없이 데이터가 그대로 돌아왔습니다. RLS가 꺼져 있었던 것입니다. 스키마를 훑어보니 약 475만 건의 레코드가 열려 있었고, 그 안에 API 인증 토큰 150만 개, 소유자 이메일 3만 5천 개, 사전 등록자 이메일 2만 9,631개, 에이전트끼리 주고받은 비공개 메시지가 들어 있었습니다.
Wiz는 이렇게 적었습니다. 제대로 설정하면 공개 키는 노출해도 안전하지만, RLS 정책이 없으면 그 키는 키를 가진 누구에게나 데이터베이스 전체 접근 권한을 준다. Moltbook의 구현에는 이 결정적인 방어선이 빠져 있었다. Wiz가 공개한 타임라인 기준으로 1월 31일 21:48 UTC에 최초 통보가 있었고, 2월 1일 01:00 UTC에 모든 테이블이 조치되며 수정이 끝났습니다.
이 사고에서 가져갈 교훈은 「바이브 코딩은 위험하다」가 아닙니다. AI는 동작하는 코드를 만드는 데 최적화돼 있고, 접근 권한 설계까지 대신 판단해 주지는 않는다는 것입니다. Wiz도 같은 취지로 적었습니다.
3분 점검 하나 — 배포된 내 사이트에서 키 찾기
먼저 내 앱이 실제로 무엇을 브라우저에 내보내고 있는지 봅니다. 개발 중인 화면이 아니라 인터넷에 올라간 주소로 접속합니다.
크롬에서 그 페이지를 열고 F12(맥은 Command+Option+I)를 눌러 개발자도구를 엽니다. Sources 탭으로 가서 Command+F 또는 Ctrl+F로 전체 검색을 열고 supabase를 입력합니다. Network 탭에서 새로고침한 뒤 검색해도 같은 것이 보입니다.
여기서 프로젝트 주소와 sb_publishable_ 또는 eyJ로 시작하는 긴 문자열이 보이면 정상입니다. 다시 말하지만 이게 보이는 것 자체는 사고가 아닙니다. 브라우저에서 데이터베이스를 부르는 구조라면 안 보일 수가 없습니다.
대신 여기서 sb_secret_으로 시작하는 값이나 service_role이라는 단어가 보이면 그건 즉시 조치할 사고입니다. 그 키는 RLS를 통째로 우회하므로 정책을 아무리 잘 써도 소용이 없습니다. 발견하면 Supabase 대시보드에서 그 키를 폐기(rotate)하고, 그 키를 쓰던 코드를 서버 쪽으로 옮겨야 합니다.
3분 점검 둘 — Table Editor에서 Unrestricted 찾기
이제 진짜 방어선을 봅니다. supabase.com에 로그인해 프로젝트를 열고 왼쪽 메뉴에서 Table Editor로 들어갑니다.
테이블 목록에서 RLS가 꺼진 테이블에는 보호되지 않았다는 표시(Unrestricted 배지)가 붙습니다. 이 표시가 붙은 테이블은 지금 이 순간 publishable 키를 가진 누구나 읽고 쓸 수 있는 상태입니다.
대시보드의 Table Editor로 만든 테이블은 RLS가 기본으로 켜집니다. 위험한 것은 SQL Editor에서 create table 문으로 만든 테이블입니다. AI에게 「테이블 만들어줘」라고 시키면 대개 이 경로로 만들어지고, 그때 RLS는 꺼진 채로 남습니다. 바이브 코딩으로 만든 앱이 이 함정에 자주 걸리는 이유가 여기 있습니다.
테이블이 여러 개면 전부 봅니다. 한 테이블만 열려 있어도 그 테이블에 담긴 것은 공개된 것과 같습니다.
켜는 것은 한 줄, 그리고 화면이 비어 보이는 이유
왼쪽 메뉴의 SQL Editor를 열고 테이블마다 한 줄을 실행합니다. profiles 자리에 자기 테이블 이름을 넣으면 됩니다.
켜고 나서 앱을 새로고침하면 목록이 텅 비어 보일 수 있습니다. 놀라지 않아도 됩니다. 고장이 아니라 규칙대로 동작한 것입니다. Supabase 문서가 명시하듯, RLS를 켜고 정책을 하나도 만들지 않으면 publishable 키로는 어떤 데이터에도 접근되지 않습니다.
데이터가 사라진 것이 아닙니다. 데이터베이스에는 그대로 있고, 다만 「이 요청자에게 보여 줘도 된다」고 말해 주는 정책이 아직 없을 뿐입니다. 다음 절에서 그 정책을 붙입니다.
순서를 반대로 하지 마십시오. 정책부터 만들고 RLS를 나중에 켜는 것이 아니라, 켜서 전부 막은 다음 필요한 것만 여는 순서가 맞습니다. 그래야 빠뜨린 테이블이 열린 채로 남지 않습니다.
alter table profiles enable row level security;정책 쓰기 — 읽기부터 붙인다

정책은 「어떤 동작을(select·insert·update·delete) 누구에게(anon·authenticated) 어떤 조건에서 허용하는가」를 적는 문장입니다. 이름은 나중에 알아보기 쉽게 붙입니다.
공지사항처럼 로그인 없이 누구나 봐도 되는 표라면 이렇게 씁니다. to anon은 로그인하지 않은 방문자를 가리키고, using ( true )는 조건 없이 전부 허용한다는 뜻입니다.
로그인한 사람이 자기 것만 보는 표라면 조건이 달라집니다. auth.uid()는 지금 로그인한 사용자의 식별자이고, 그것이 행의 user_id와 같을 때만 그 행을 내줍니다.
쓰기는 읽기와 따로 걸어야 합니다. select 정책만 붙이면 읽기만 열리고 insert·update·delete는 계속 막혀 있습니다. 각각 필요한 정책을 따로 쓰는 것이 맞고, 기본이 막힌 상태인 것이 안전합니다.
여기서 흔한 실수 하나. 화면이 비어 보이는 게 답답해서 모든 테이블에 using ( true )를 발라 버리는 것입니다. 그러면 RLS를 켜기 전과 똑같은 상태가 됩니다. 화면이 도는 것이 목표가 아니라 필요한 것만 나가는 것이 목표입니다.
create policy anyone_can_read
on notices for select
to anon
using ( true );create policy own_rows_only
on profiles for select
using ( (select auth.uid()) = user_id );AI에게 어떻게 시키고, 오늘 무엇을 남기나
이 점검은 AI에게 맡길 수 있습니다. 다만 지시문이 구체적이어야 합니다. 「보안 점검해줘」라고 하면 일반론이 돌아오고, 최악의 경우 전부 열어 버리는 정책을 제안합니다.
확인용 SQL도 하나 알아 두면 좋습니다. 대시보드를 테이블마다 클릭하지 않고 목록으로 한 번에 볼 수 있습니다. rowsecurity가 false인 줄이 지금 열려 있는 테이블입니다.
오늘 남길 것은 세 가지입니다. 첫째, 배포된 내 앱에서 secret 키가 안 보인다는 확인. 둘째, public 스키마의 모든 테이블에서 RLS가 켜져 있다는 확인. 셋째, 테이블마다 왜 그 정책인지 한 줄 메모. 세 번째가 다음에 테이블을 추가할 때 같은 실수를 막아 줍니다.
이 셋은 서비스를 만들기 전에 하는 일이 아니라 이미 올려 둔 것에 오늘 하는 일입니다. Moltbook은 사람이 몰려드는 동안 열려 있었고, 고치는 데는 몇 시간이 걸렸습니다.
Supabase 프로젝트의 public 스키마 테이블 전부에 대해 RLS 활성화 여부를 표로 정리해줘.
꺼진 테이블마다 (1) 담긴 데이터가 공개돼도 되는지 나에게 먼저 묻고,
(2) 내 답에 맞춰 alter table 문과 최소 권한 정책 SQL을 따로 제안해줘.
정책은 select·insert·update·delete를 각각 쓰고,
using ( true )는 내가 공개라고 답한 테이블에만 써.select tablename, rowsecurity
from pg_tables
where schemaname = 'public'
order by rowsecurity, tablename;