바이브 코딩으로 만든 Supabase 앱, 요청의 user_id만 바꾸면 남의 데이터가 보입니다. 어디가 뚫린 건가요?
Cursor + Supabase로 주말에 사이드 프로젝트(회원 메모 앱)를 거의 vibe coding으로 만들었습니다. 로그인/회원가입은 다 됩니다. 그런데 친구가 브라우저 네트워크 탭에서 GET /rest/v1/notes?user_id=eq.<남의 uuid> 요청의 user_id 값만 다른 사람 uuid로 바꿨더니 남의 메모가 그대로 응답으로 왔습니다.
- 프론트: Next.js(App Router),
@supabase/supabase-js클라이언트에서 직접 쿼리 .env에NEXT_PUBLIC_SUPABASE_ANON_KEY사용- 코드에서는
.eq('user_id', session.user.id)로 분명히 필터하는데도 우회됩니다
AI가 짜준 코드라 어디가 구멍인지 모르겠습니다. 어디부터 봐야 하나요?
답변 1개
- 채택된 답변에디터 검증
결론부터: 클라이언트의
.eq('user_id', ...)는 보안 장치가 아니라 그냥 쿼리 파라미터입니다. 네트워크 탭이나curl로 누구나 값을 바꿀 수 있습니다. 실제 방어선은 DB의 RLS(Row Level Security) 정책인데, vibe coding 결과물은 RLS가 꺼져 있거나(테이블에enable row level security가 안 걸림) 정책이 한 줄도 없는 경우가 대부분입니다. 응답이 남의 행을 그대로 돌려준다는 건 이 둘 중 하나라는 신호입니다.왜
.eq()가 무력한가anon키로 나가는 PostgREST 요청에서user_id=eq.<uuid>는 SQL로 번역되면 그냥WHERE user_id = '<uuid>'입니다. 이 값은 클라이언트가 보낸 것이라 공격자가 임의로 바꿀 수 있습니다. RLS 정책이 없으면 Postgres는 그WHERE를 그대로 실행해 남의 행을 돌려줍니다. "코드에서 필터했으니 안전"은 곧 "클라이언트를 신뢰한다"는 뜻이라 성립하지 않습니다.1분 진단
-- Supabase SQL Editor에서 실행: RLS 꺼진 public 테이블 찾기 select relname, relrowsecurity from pg_class where relnamespace = 'public'::regnamespace and relkind = 'r'; -- relrowsecurity = false 인 테이블이 전부 구멍 -- 테이블별 정책 개수 확인 select tablename, count(*) from pg_policies where schemaname = 'public' group by tablename; -- notes가 목록에 없으면 정책 0개 = 무방비그리고
curl로 직접 재현해 보세요. 이게 데이터를 돌려주면 확정입니다.curl 'https://<ref>.supabase.co/rest/v1/notes?user_id=eq.<남의uuid>' \ -H "apikey: <ANON_KEY>" -H "Authorization: Bearer <ANON_KEY>"고치는 코드
alter table public.notes enable row level security; -- auth.uid()는 요청 JWT에서 서버가 끌어오는 값 → 클라이언트가 위조 불가 create policy "own notes select" on public.notes for select using (auth.uid() = user_id); create policy "own notes insert" on public.notes for insert with check (auth.uid() = user_id); create policy "own notes update" on public.notes for update using (auth.uid() = user_id) with check (auth.uid() = user_id); create policy "own notes delete" on public.notes for delete using (auth.uid() = user_id);핵심은 클라이언트가 보낸
user_id가 아니라 JWT에서 추출되는auth.uid()로 비교한다는 점입니다. 적용 후 위curl이 빈 배열[]을 돌려주면 막힌 겁니다.같이 확인할 함정
- service_role 키 노출:
NEXT_PUBLIC_*에service_role키를 넣으면 RLS를 통째로 우회합니다. 클라이언트 번들에service_role이 섞이면 RLS를 아무리 짜도 무의미합니다.grep -rn service_role .next/ public/로 키가 빌드 산출물에 들어갔는지 확인하세요. 클라이언트에는 반드시anon키만 둡니다. insert/update에with check누락:select만 막고 쓰기 정책에서with check를 빠뜨리면 남의user_id로 행을 심거나 옮길 수 있습니다.- 정책 있어도 RLS 비활성: 정책을 만들어 놓고
enable row level security를 안 걸면 정책이 평가되지 않습니다. 위relrowsecurity쿼리로 둘 다 확인하세요. - storage 버킷: notes에 첨부가 있으면 Storage 객체도 같은 우회가 가능합니다. 버킷 정책도 별도로 확인합니다.
한국 맥락 / 교훈
메모 UI 정도는 vibe coding으로 빠르게 만들어도 되지만, PII·결제·인증이 닿는 테이블은 vibe로 맡기면 안 됩니다. 한국이면 회원 메모도 개인정보보호법상 개인정보 처리이고, 이런 유출은 단순 버그가 아니라 신고 대상이 될 수 있습니다. 순서를 뒤집으세요:
auth.uid()기반 RLS를 먼저 "계약"으로 깔고, 그 위에서만 vibe로 UI를 굴립니다. AI가 짠 DB 접근 경로의 diff는 반드시 직접 읽어야 합니다. - service_role 키 노출: