Insights·2026-08-14

Supabase RLS — 내 앱의 데이터베이스가 지금 누구에게나 열려 있는가

Supabase로 만든 앱은 브라우저에 키가 노출되는 것이 정상이고, 그 키가 안전한지는 테이블마다 RLS(Row Level Security)가 켜져 있는지 하나로 갈립니다. RLS가 꺼져 있으면 그 키를 가진 누구나 데이터베이스를 읽고 쓸 수 있습니다. 2026년 2월 보안업체 Wiz가 공개한 Moltbook 사고가 정확히 그 경우였고, 브라우저 자바스크립트에 박힌 publishable 키 하나로 약 475만 건의 레코드가 열려 있었습니다. 확인은 배포된 사이트에서 키를 찾고 Supabase 대시보드 Table Editor에서 Unrestricted 배지를 보는 두 단계로 3분이면 끝납니다. 이 글은 그 점검 절차와, RLS를 켠 뒤 화면이 비어 보이는 이유, 정책을 어떤 순서로 붙이는지를 처음 하는 사람 기준으로 설명합니다.

Supabase RLS 요약 도식. 위쪽은 「RLS 꺼짐」과 「RLS 켜짐」을 좌우로 나란히 놓았다. 꺼진 쪽에는 publishable 키를 가진 누구나 데이터베이스를 읽고 쓸 수 있다는 설명과, Moltbook이 브라우저 키 하나로 약 475만 건의 레코드를 열어 두고 있었다는 사례가 적혀 있다. 켜진 쪽에는 alter table … enable row level security 명령과 함께, 정책이 허용한 것만 나가며 정책을 하나도 만들지 않으면 아무 데이터에도 접근되지 않는다는 설명이 붙어 있다. 가운데는 점검 3단계다 — 배포된 사이트에서 F12 → Sources → supabase로 키 찾기, Table Editor에서 Unrestricted 배지 찾기, 켜고 정책 붙이기. 아래는 Supabase SQL Editor 화면으로, 테이블마다 한 줄인 alter table profiles enable row level security와, 지금 열려 있는 테이블을 rowsecurity가 false인 줄로 찾는 select tablename, rowsecurity from pg_tables where schemaname = 'public' order by rowsecurity 질의가 찍혀 있다.
왼쪽과 오른쪽을 가르는 것은 키가 아니라 RLS 한 줄이다. 켠 직후 화면이 비어 보이는 것은 고장이 아니라 정책이 아직 없는 것이다.

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 사고 요약 도식. 제목은 「권한 오류 없이 데이터가 그대로 돌아왔다」이고, 공개 키로 열려 있던 것을 네 줄로 나열한다 — API 인증 토큰 150만 개는 키 하나로 그대로 조회됐고, 소유자 이메일 3만 5천 개와 사전 등록자 이메일 2만 9,631개가 함께 있었으며, 에이전트끼리 주고받은 비공개 메시지도 열려 있었다. 마지막 줄은 「통보 21:48 → 수정 01:00」이고 Wiz 공개 타임라인 UTC 기준이라고 적혀 있다.

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를 나중에 켜는 것이 아니라, 켜서 전부 막은 다음 필요한 것만 여는 순서가 맞습니다. 그래야 빠뜨린 테이블이 열린 채로 남지 않습니다.

SQL Editor
alter table profiles enable row level security;

정책 쓰기 — 읽기부터 붙인다

RLS 정책 SQL 두 가지 — 누구나 읽는 표에는 to anon과 using ( true ), 본인 행만 보는 표에는 auth.uid() 조건을 쓰는 예시.

정책은 「어떤 동작을(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은 사람이 몰려드는 동안 열려 있었고, 고치는 데는 몇 시간이 걸렸습니다.

AI에게 줄 지시문
Supabase 프로젝트의 public 스키마 테이블 전부에 대해 RLS 활성화 여부를 표로 정리해줘.
꺼진 테이블마다 (1) 담긴 데이터가 공개돼도 되는지 나에게 먼저 묻고,
(2) 내 답에 맞춰 alter table 문과 최소 권한 정책 SQL을 따로 제안해줘.
정책은 select·insert·update·delete를 각각 쓰고,
using ( true )는 내가 공개라고 답한 테이블에만 써.
RLS 상태 한눈에 보기
select tablename, rowsecurity
from pg_tables
where schemaname = 'public'
order by rowsecurity, tablename;
출처: Wiz Research