Supabase RLS 보안 가이드: anon 키 노출 전제의 정책 설정
Supabase RLS를 켜는 법부터 느슨한 정책, service_role 키 오용 같은 흔한 설정 실수와 고친 코드, 바로 돌려볼 수 있는 점검 SQL과 체크리스트까지 정리했습니다.
Supabase의 anon 키(새 체계에서는 publishable 키)는 원래 브라우저 번들에 들어가도록 만든 키입니다. 그러니 키를 숨기는 데 힘을 쓰기보다, 키를 가진 사람이 무엇을 할 수 있는지를 RLS(Row Level Security)로 정하는 것이 데이터를 지키는 방법입니다. 공개 스키마(public)의 모든 테이블에 RLS를 켜고, 조회·생성·수정·삭제 작업마다 auth.uid()로 소유자를 확인하는 정책을 따로 쓰면 됩니다. service_role(secret) 키는 서버에만 둡니다.
진단 현장에서 보면 문제는 대개 세 가지로 모입니다. RLS를 아예 켜지 않은 테이블, 켜기는 했지만 using (true)처럼 사실상 전부 허용하는 정책, 그리고 RLS를 통째로 우회하는 service_role 키를 잘못 쓰는 경우입니다. 이 글에서는 세 가지를 취약한 코드와 고친 코드로 비교하고, Supabase SQL Editor에서 바로 돌려볼 수 있는 점검 쿼리를 정리합니다.
배경을 조금 덧붙이면, OWASP Top 10 2025에서도 접근 제어 실패(Broken Access Control)는 여전히 1위입니다. Supabase는 프론트엔드가 데이터베이스 API를 직접 호출하는 구조라서, 이 위험이 백엔드 코드가 아니라 DB 정책 한 줄에 달려 있다는 점이 다릅니다.
anon 키가 공개돼도 괜찮은 조건
Supabase 공식 문서는 publishable 키를 웹 페이지, 모바일 앱, 소스 코드에 노출해도 되는 키로 설명합니다. 동시에 이 키의 보호는 "1차 방어"일 뿐이라고 분명히 적어 둡니다. 공개 코드에서 언제든 꺼낼 수 있기 때문입니다.
요청은 Postgres 역할로 바뀌어 실행됩니다.
- 로그인하지 않은 요청 →
anon역할 - 로그인한 사용자의 액세스 토큰이 붙은 요청 →
authenticated역할 - secret 키(레거시
service_role) →BYPASSRLS속성을 가진service_role역할
여기서 판단 기준이 하나 나옵니다. 회원가입이 열려 있는 서비스라면 authenticated는 사실상 "아무나"와 같습니다. 누구나 계정을 만들 수 있으니 "로그인만 하면 전부 조회 가능"한 정책은 공개 정책이나 다름없습니다. 익명 로그인(Anonymous Sign-ins)을 켜 두었다면 이 범위는 더 넓어집니다.
참고로 Supabase는 레거시 anon·service_role 키를 2026년 말까지 폐기(deprecate)할 예정이라고 공지했습니다. 새 publishable·secret 키를 만들어도 레거시 키는 대시보드에서 직접 비활성화하기 전까지 계속 유효하므로, 키를 옮길 때는 비활성화까지 끝내야 합니다.
실수 1: RLS를 켜지 않은 테이블
SQL 마이그레이션으로 테이블을 만들거나 예전에 만든 프로젝트를 이어서 쓰면, RLS가 꺼진 테이블이 섞여 있는 경우가 많습니다. 공개 스키마에서 RLS가 꺼진 테이블은 anon 키만 있으면 Data API로 읽고 쓸 수 있습니다.
-- 취약: RLS 없이 생성
create table public.orders (
id bigint generated always as identity primary key,
user_id uuid not null references auth.users (id),
amount integer not null,
memo text
);
-- 수정: 생성 직후 RLS 활성화
alter table public.orders enable row level security;
RLS를 켜고 정책을 하나도 만들지 않으면 publishable 키로는 아무 데이터도 돌아오지 않습니다(기본 거부). 이 상태에서 필요한 정책만 하나씩 추가하는 순서가 안전합니다.
공식 문서에는 하나 더 주의할 점이 있습니다. 기존 프로젝트는 새 테이블에 anon·authenticated 역할로 모든 권한(GRANT)을 주고 있을 수 있고, 정책을 추가해도 이 권한은 회수되지 않습니다. 익명 사용자가 쓸 일이 없는 테이블이라면 권한도 같이 정리합니다.
revoke all on public.orders from anon;
실수 2: 켜놓고 느슨한 정책
RLS가 켜져 있다고 끝은 아닙니다. 실제로는 정책 내용이 더 자주 문제가 됩니다.
using (true)와 "로그인만 하면 허용"
-- 취약: 누구나 모든 주문을 조회
create policy "orders read" on public.orders
for select using (true);
-- 취약: 로그인 사용자 누구나 모든 주문을 조회
create policy "orders read auth" on public.orders
for select to authenticated using (true);
-- 수정: 본인 행만, 대상 역할 명시
create policy "orders select own" on public.orders
for select to authenticated
using ((select auth.uid()) = user_id);
auth.uid()를 (select ...)로 감싸는 건 공식 문서가 권하는 성능 패턴입니다. 행마다 함수를 다시 부르지 않고 문장 단위로 결과를 캐시합니다. user_id 컬럼에는 인덱스를 걸어 둡니다.
이 실수는 API 계층에서 생기는 IDOR와 구조가 같습니다. 요청한 사람이 그 행의 주인인지 확인하지 않는 것이죠. 개념은 IDOR 취약점이란? 주문·예약 API로 보는 원인과 방어법에서 더 자세히 다뤘습니다.
INSERT에서 소유자 검증 누락
-- 취약: 다른 사람 user_id로 행 생성 가능
create policy "orders insert" on public.orders
for insert to authenticated with check (true);
-- 수정
create policy "orders insert own" on public.orders
for insert to authenticated
with check ((select auth.uid()) = user_id);
RLS는 "행" 단위이지 "컬럼" 단위가 아니다
본인 행 수정을 허용하는 정책은 그 행의 모든 컬럼 수정을 허용합니다. profiles 테이블에 role이나 plan 컬럼이 있다면 사용자가 자기 등급을 직접 바꿀 수 있습니다.
-- 취약: 본인 프로필이면 role 컬럼까지 수정 가능
create policy "profiles update own" on public.profiles
for update to authenticated
using ((select auth.uid()) = id)
with check ((select auth.uid()) = id);
-- 수정: 정책은 유지하되, 수정 가능한 컬럼을 권한으로 제한
revoke update on public.profiles from authenticated;
grant update (nickname, avatar_url) on public.profiles to authenticated;
권한 관련 값은 별도 테이블로 빼고 서버에서만 바꾸는 방식이 더 깔끔합니다. 주문 금액처럼 신뢰하면 안 되는 값을 클라이언트가 직접 쓰는 구조도 같은 문제입니다. 금액 검증은 결제 금액 변조 취약점: PG 연동 단계별 서버 검증 설계를 참고하세요.
user_metadata로 권한 판단
raw_user_meta_data(JWT의 user_metadata)는 로그인한 사용자가 직접 바꿀 수 있습니다. 공식 문서도 권한 데이터를 여기에 두지 말라고 경고합니다.
-- 취약: 사용자가 스스로 role=admin으로 바꿀 수 있음
using ((auth.jwt() -> 'user_metadata' ->> 'role') = 'admin')
-- 수정: 사용자가 바꿀 수 없는 app_metadata 사용
using ((auth.jwt() -> 'app_metadata' ->> 'role') = 'admin')
다만 JWT 클레임은 토큰이 갱신될 때까지 예전 값이 남아 있을 수 있습니다. 권한을 회수했을 때 즉시 반영돼야 한다면 멤버십 테이블을 조회하는 방식이 낫습니다.
실수 3: service_role(secret) 키 오용
secret 키는 모든 RLS 정책을 우회합니다. 흔한 실수는 두 가지입니다.
첫째, 프론트엔드 환경 변수에 넣는 경우. NEXT_PUBLIC_, VITE_ 같은 접두사가 붙은 변수는 빌드할 때 번들에 그대로 들어갑니다. 새 secret 키는 브라우저 User-Agent로 들어온 요청을 401로 거절하지만, 공식 문서 말대로 다른 도구로는 여전히 쓸 수 있습니다. 노출됐다면 바로 삭제하고 새로 발급해야 합니다.
둘째, 서버에서 secret 키를 쓰면서 클라이언트가 보낸 ID를 그대로 믿는 경우. 서버 코드라서 안전해 보이지만, 이렇게 하면 RLS가 해 주던 일을 직접 해야 합니다.
// 취약: secret 키 클라이언트 + 요청 body의 userId를 신뢰
const admin = createClient(SUPABASE_URL, process.env.SUPABASE_SECRET_KEY!);
export async function POST(req: Request) {
const { userId } = await req.json();
const { data } = await admin.from("orders").select("*").eq("user_id", userId);
return Response.json(data);
}
// 수정: 사용자 토큰으로 클라이언트를 만들어 RLS가 적용되게 함
export async function POST(req: Request) {
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
if (!token) return new Response("Unauthorized", { status: 401 });
const supabase = createClient(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, {
global: { headers: { Authorization: `Bearer ${token}` } },
});
const { data: { user }, error } = await supabase.auth.getUser();
if (error || !user) return new Response("Unauthorized", { status: 401 });
const { data } = await supabase.from("orders").select("*"); // RLS가 본인 행만 반환
return Response.json(data);
}
판단 기준은 간단합니다. 특정 사용자를 대신해 실행하는 작업은 사용자 토큰으로, 시스템 작업(웹훅 처리, 배치, 관리자 기능)만 secret 키로 합니다. secret 키를 써야 한다면 사용자 ID는 요청 body가 아니라 검증된 세션에서 꺼냅니다.
놓치기 쉬운 우회 경로: 뷰, 함수, Storage
- 뷰(View): 뷰는 보통
postgres사용자로 만들어지기 때문에 기본적으로 RLS를 우회합니다. Postgres 15 이상에서는create view ... with (security_invoker = true)로 만들어 원본 테이블 정책이 적용되게 합니다. - security definer 함수: 만든 사람의 권한으로 실행되므로 RLS를 건너뛸 수 있습니다. 노출된 스키마에 두지 말고,
set search_path = ''로 고정하고 모든 객체 이름에 스키마를 붙입니다. 팀 멤버십 확인처럼 꼭 필요한 경우에만 비공개 스키마에 둡니다. - Storage: 파일 접근도
storage.objects테이블의 정책으로 제어합니다. 공개 버킷은 목록 조회가 가능한지 따로 확인합니다.
-- 수정 예: 원본 테이블 RLS를 따르는 뷰
create view public.my_orders
with (security_invoker = true) as
select id, amount, memo from public.orders;
바로 돌려보는 점검 SQL
아래 쿼리는 읽기 전용입니다. 결과가 나오면 의도한 설계인지 하나씩 확인합니다.
-- 1) public 스키마에서 RLS가 꺼진 테이블
select tablename
from pg_tables
where schemaname = 'public' and rowsecurity = false;
-- 2) 무조건 허용 정책 (using/with check 가 true)
select tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public'
and (qual = 'true' or with_check = 'true');
-- 3) user_metadata를 참조하는 정책
select tablename, policyname, qual, with_check
from pg_policies
where coalesce(qual, '') || coalesce(with_check, '') ilike '%user_metadata%';
-- 4) public 스키마의 뷰와 옵션 (security_invoker 없으면 검토)
select c.relname as view_name, c.reloptions
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'v';
-- 5) public 스키마의 security definer 함수와 search_path 설정
select p.proname, p.proconfig
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.prosecdef;
대시보드의 Advisors > Security Advisor(CLI는 supabase db advisors)도 같이 돌려 보세요. RLS가 꺼진 공개 테이블, RLS를 우회하는 뷰, user_metadata 참조 정책, 무제한 허용 정책 등을 자동으로 잡아 줍니다. 다만 정책 로직이 비즈니스 의도에 맞는지까지는 판단하지 못하니, 마지막 검증은 사람이 해야 합니다.
배포 전 체크리스트
-
public등 노출 스키마의 모든 테이블에 RLS가 켜져 있다 - 정책이 select/insert/update/delete별로 나뉘어 있고
to역할이 명시돼 있다 - insert·update 정책에
with check로 소유자 검증이 있다 - 사용자가 바꾸면 안 되는 컬럼(role, plan, amount 등)은 컬럼 권한이나 별도 테이블로 막았다
- 권한 판단에
user_metadata를 쓰지 않는다 - 뷰는
security_invoker = true이고, security definer 함수는 비공개 스키마에 있다 - secret/service_role 키가 프론트엔드 환경 변수나 저장소에 없다
- 다른 계정 두 개로 서로의 데이터를 조회·수정해 보는 테스트(pgTAP 등)가 있다
- 레거시 키를 옮겼다면 대시보드에서 비활성화까지 했다
자주 묻는 질문
anon 키가 GitHub에 올라갔는데 바로 교체해야 하나요?
anon·publishable 키는 원래 공개를 전제로 한 키라서, 노출 자체가 사고는 아닙니다. 대신 이번 기회에 위의 점검 SQL로 RLS 상태를 확인하세요. 같은 커밋에 secret/service_role 키가 함께 들어갔다면 그건 즉시 교체해야 합니다.
RLS를 켰더니 UPDATE가 안 됩니다.
공식 문서에 따르면 UPDATE가 정상 동작하려면 해당 행을 볼 수 있는 SELECT 정책이 필요합니다. 그리고 정책이 막아서 0건이 수정될 때는 에러가 나지 않으니, 테스트할 때 수정된 행 수를 같이 확인하세요.
Edge Function 안에서는 service_role 키를 써도 되나요?
서버 환경이라 키가 노출될 위험은 낮습니다. 하지만 RLS가 꺼지는 것과 같으니, 사용자 요청을 처리하는 함수라면 사용자 토큰으로 클라이언트를 만들어 RLS를 적용받는 쪽을 기본으로 하세요. secret 키는 관리자 작업에만 씁니다.
정리
- anon(publishable) 키는 공개되는 키이고, 데이터를 지키는 것은 RLS 정책입니다.
- 노출 스키마의 모든 테이블에 RLS를 켜고, 작업별로
auth.uid()소유자 검증과with check를 둡니다. 행 단위로 못 막는 컬럼은 권한으로 막습니다. - secret/service_role 키는 서버의 시스템 작업에만 쓰고, 사용자 요청은 사용자 토큰으로 처리합니다.
- 점검 SQL과 Security Advisor를 배포 루틴에 넣어 두면 대부분의 설정 실수를 미리 찾을 수 있고, 직접 점검이 어렵다면 무료 분석 신청으로 확인받아 볼 수도 있습니다.
참고 자료
함께 읽으면 좋은 글
AWS 보안 설정 가이드: 초기 스타트업이 하루에 잠글 5가지
AWS 보안 설정을 루트 계정, IAM, S3, 보안 그룹, 감사 로그 순으로 정리했다. 소규모 팀이 하루 안에 점검하고 고칠 수 있는 명령어와 수정 전후 예시, 판단 기준을 담았다.
취약점 점검 리포트 읽는 법: 위험도 해석부터 조치 우선순위까지
취약점 점검 리포트의 위험도를 서비스 맥락으로 해석하고, 조치 순서 결정, 오탐 확인, 재점검 요청, 수정 완료 근거 정리까지 개발팀의 처리 흐름을 안내합니다.
개인정보 유출 사고 사례: 개인정보위 처분에서 반복된 원인 5가지
개인정보 유출 사고 사례를 개인정보위 처분 기준으로 분석해 반복된 원인 5가지를 정리하고, 개발팀이 바로 쓸 코드 예시와 점검 항목으로 바꿔 줍니다.