403 Forbidden, RLS, 권한 | 슈퍼베이스에서 select는 되고 insert만 막히면?
조회는 되고 저장만 403이면 행 수준 보안의 넣기 정책이 그 행을 거절한 상태다. 조회 조건과 쓰기 검사, 사용자 아이디, 익명 키와 서비스 롤 키를 나눈다. 슈퍼베이스, 포스트그레, Next.js, API, 백엔드, 보안, 카카오 로그인, 서버 액션, 개발자 기준. 2026년 9월 MDN, 포스트그레, 슈퍼베이스 공식 문서.
조회는 되고 저장만 403이면 로그인이 풀린 게 아니라, 조회 정책은 통과했는데 넣기 정책의 쓰기 검사가 그 행을 거절한 상태입니다. 화면 세션을 고치기 전에 조회 조건과 쓰기 검사, 요청에 실린 사용자 아이디부터 보세요.
목록은 뜨는데 글 저장만 막히는 일, 슈퍼베이스에 테이블을 열고 행 수준 보안을 켠 직후 자주 납니다. 제가 문의 글을 붙일 때도 공개 조회만 두고 넣기 정책을 빼먹으면 같은 403이 납니다.
만료 토큰 글과 자리는 다릅니다. 401은 자격이 없거나 만료된 쪽이고, 여기 403은 누구인지는 아는데 그 행을 쓸 권한이 없는 쪽입니다. 근거는 MDN 403과 슈퍼베이스 행 수준 보안, 포스트그레 정책 생성에 있습니다.
조회는 되고 저장만 막히면 어디를 보나
넣기 정책의 쓰기 검사부터 엽니다. 행 수준 보안이 켜진 테이블은 명령마다 정책이 따로입니다. 조회만 열어 두면 목록은 오고, 새 행을 넣는 순간 거절됩니다.
브라우저 네트워크 칸에 403이 찍히고, 본문에 42501이나 new row violates row-level security policy가 있으면 그 자리입니다. 포스트그레는 권한 부족을 그 코드로 내고, 슈퍼베이스 데이터 API는 HTTP 403으로 감싸 돌려 줍니다. 토큰이 없어서 거절되는 401과 메시지를 섞지 마세요. 401은 만료 토큰 글에서 다룹니다.
공개 조회에 로그인 넣기를 붙인 게시판이 전형적인 장면입니다. 조회 정책이 참이면 누구나 목록을 읽고, 넣기는 로그인된 역할과 자기 아이디 행만 통과합니다. 세션 쿠키는 살아 있는데 넣기만 막히면, 화면 로그인이 아니라 그 요청이 어떤 역할로 테이블에 들어갔는지를 보세요.
먼저 기억할 것 | 조회가 된다는 건 조회 정책과 조회 권한이 있다는 뜻입니다. 저장 403은 넣기 권한 또는 넣기 정책이 그 새 행을 통과시키지 못한 쪽입니다. 로그인 화면부터 다시 짜지 마세요.
조회 정책과 넣기 정책은 따로다. 목록이 뜬다고 저장이 되는 건 아니다
조회 조건과 쓰기 검사가 다른 칸
조회는 기존 행을 보고, 넣기는 새로 들어오는 행만 검사합니다. 포스트그레 정책에서 조회 조건은 USING, 쓰기 검사는 WITH CHECK입니다. 명령마다 어느 쪽이 살아 있는지 표로 먼저 나눕니다.
명령
조회 조건 USING
쓰기 검사 WITH CHECK
조회
기존 행을 보이게 할지
없음
넣기
없음. 새 행이라 기존 값이 없음
들어오는 행이 허용인지
수정
지금 행을 건드릴 수 있는지
바뀐 뒤 행도 허용인지
삭제
지금 행을 지울 수 있는지
없음
슈퍼베이스 공식 문서도 넣기는 쓰기 검사로 새 행을 본다고 적습니다. 조회 정책만 자기 아이디로 열어 두고 넣기 정책을 안 만들면, 목록은 자기 글만 보이고 저장은 403입니다. 반대로 조회를 모두에게 열어 두고 넣기만 자기 아이디로 막으면, 누구나 목록은 보는데 로그인 없이 저장하면 막힙니다.
정책 전체를 한 방에 쓰는 방식은 어떤 명령이 어떤 칸을 보는지 가립니다. 조회, 넣기, 수정, 삭제를 명령별로 나누는 쪽이 원인 추적이 빠릅니다. 심층 패턴 전체는 행 수준 보안 정책 심층에 두고, 여기서는 조회는 되는데 넣기만 403인 줄만 봅니다.
공개 조회와 자기 행 넣기를 명령별로 나누기
-- 목록은 누구나, 저장은 로그인된 자기 아이디만
create policy "notes_select_public"
on public.notes
for select
to anon, authenticated
using ( true );
create policy "notes_insert_own"
on public.notes
for insert
to authenticated
with check ( (select auth.uid()) = user_id );
정책은 있는데 사용자 아이디가 빈 경우
정책이 있어도 사용자 아이디가 비면 쓰기 검사는 거짓이 됩니다. 슈퍼베이스 문서는 토큰이 없거나 세션이 끊기면 auth.uid()가 널이라고 적습니다. 널과 컬럼 값은 같지 않으니, 넣기는 바로 거절됩니다.
클라이언트에서 user_id를 안 넣고 기본값도 없으면 같은 줄입니다. 쓰기 검사는 들어오는 행의 그 칸을 보는데, 칸이 널이면 자기 아이디와 비교가 실패합니다. 컬럼 기본값을 auth.uid()로 두면 클라이언트가 빼먹어도 서버가 채웁니다.
카카오 로그인으로 화면은 살아 있는데, 슈퍼베이스 세션에 액세스 토큰이 안 실리면 요청은 익명 역할로 들어갑니다. 넣기 정책이 로그인된 역할만 받으면 익명은 정책 전에 권한에서 잘리거나, 정책의 아이디 비교에서 떨어집니다. 토큰 만료는 401 쪽이고, 여기선 요청에 사용자 아이디가 실리는지만 확인하세요.
넣기 컬럼 기본값과 아이디 확인
alter table public.notes
alter column user_id set default (select auth.uid());
-- 저장 직전에 세션 아이디가 있는지
-- 널이면 익명 역할로 들어간 것
select auth.uid();
화면 로그인과 테이블에 실리는 사용자 아이디는 다를 수 있다
서비스 롤 키를 브라우저에 두면 안 되는 이유
서비스 롤 키는 행 수준 보안을 건너뛰므로 브라우저에 두면 안 됩니다. 슈퍼베이스는 비밀 키가 서비스 롤로 붙고, 그 역할은 정책을 우회한다고 적습니다. 넣기 403이 귀찮다고 그 키를 프론트 환경 변수에 넣으면, 누구나 모든 행을 읽고 쓸 수 있습니다.
브라우저와 서버 액션은 익명 키와 사용자 세션만 씁니다. 관리자 작업, 웹훅으로 받은 주문을 넣는 작업, 크론으로 집계하는 작업만 서버에서 비밀 키를 엽니다. 공개 접두사가 붙은 환경 변수는 번들에 들어가니, 비밀 키 이름 앞에 그 접두사를 붙이지 마세요. 시크릿이 새는 경로는 환경변수 체크리스트와 자리가 같습니다.
한 가지 더, 비밀 키로 클라이언트를 만들어도 요청에 사용자 액세스 토큰이 실리면 그 사용자 정책으로 실행됩니다. 우회가 필요하면 사용자 토큰을 붙이지 않은 서버 전용 클라이언트를 따로 두세요. 403을 키로 뚫지 말고, 넣기 정책을 고치세요.
브라우저 익명 키, 서버만 비밀 키
// 브라우저·서버 액션: 익명 키 + 사용자 세션
import { createBrowserClient } from '@supabase/ssr'
const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
)
// 서버 전용. NEXT_PUBLIC_ 붙이지 말 것
import { createClient } from '@supabase/supabase-js'
const admin = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_SERVICE_ROLE_KEY,
{ auth: { persistSession: false, autoRefreshToken: false } }
)
테이블 권한도 같이 | 정책만 고치고 넣기 권한을 안 줬으면 정책 전에 42501이 납니다. 슈퍼베이스는 권한과 정책을 둘 다 맞추라고 합니다. 조회 권한만 있고 넣기 권한이 없으면, 목록은 되고 저장만 막히는 증상이 그대로입니다.
저장 직후 다시 읽기가 또 막힐 때
저장 직후 다시 읽으려면 조회 정책도 같은 행을 통과해야 합니다. 클라이언트에서 넣기 뒤에 바로 조회를 붙이면, 데이터 API는 새 행을 돌려 주기 위해 조회 정책을 한 번 더 탑니다.
넣기 쓰기 검사는 통과했는데 조회 조건이 그 행을 숨기면, 화면에는 또 에러처럼 보입니다. 공개 조회가 아니면서 넣기만 열어 둔 테이블에서 자주 납니다. 자기 아이디 행을 넣는 정책과, 자기 아이디 행을 읽는 정책을 짝으로 두세요.
수정은 더 까다롭습니다. 공식 문서는 수정을 하려면 조회 정책이 필요하다고 적습니다. 기존 행을 건드리는 조회 조건과, 바뀐 뒤 행을 허용하는 쓰기 검사를 같이 써야 아이디를 남의 것으로 바꿔 쓰는 구멍을 막습니다. 탈퇴 뒤 같은 이메일 재가입은 소프트 삭제와 부분 유니크 칸이고, 여기서는 권한 줄만 봅니다.
넣기와 조회를 짝으로, 수정도 두 칸
create policy "notes_select_own"
on public.notes
for select
to authenticated
using ( (select auth.uid()) = user_id );
create policy "notes_insert_own"
on public.notes
for insert
to authenticated
with check ( (select auth.uid()) = user_id );
create policy "notes_update_own"
on public.notes
for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );
넣기 뒤에 조회를 붙이면 조회 정책이 한 번 더 그 행을 검사한다
막혔을 때 확인할 순서
응답 본문, 역할, 정책, 컬럼 값 순서로 좁히면 헤매지 않습니다. 429로 한도가 막힌 줄은 너무 많은 요청이고, 여기서는 권한 줄만 봅니다.
순서
확인
통과 기준
1
상태 코드와 본문 코드
403과 42501인지, 401인지
2
요청 역할
익명인지, 로그인된 역할인지
3
테이블 넣기 권한
그 역할에 insert가 있는지
4
넣기 정책 존재
FOR INSERT와 쓰기 검사가 있는지
5
auth.uid와 user_id
널이 아니고 서로 같은지
6
넣기 뒤 조회
조회 정책이 그 행을 보여 주는지
SQL 편집기에서 역할을 바꿔 넣기를 재현하면 앱 버그와 정책 버그가 갈립니다. 공식 흐름은 로컬 역할을 로그인된 역할로 두고, 토큰의 사용자 아이디를 맞춘 뒤 같은 insert를 실행하는 것입니다. 편집기에선 되고 앱만 막히면 앱이 익명 키로 보냈거나 컬럼을 뺀 겁니다.
1인으로 넥스트와 슈퍼베이스를 붙여 운영하면, 프리뷰 도메인의 리다이렉트와 로컬 세션이 갈려 이 증상이 잘 납니다. 쿠키가 한 브라우저에서만 풀리는 줄은 SameSite 글이고, 저장 403은 정책 칸입니다.