TechFeedTechFeed
Backend

403 Forbidden, RLS, 권한 | 슈퍼베이스에서 select는 되고 insert만 막히면?

조회는 되고 저장만 403이면 행 수준 보안의 넣기 정책이 그 행을 거절한 상태다. 조회 조건과 쓰기 검사, 사용자 아이디, 익명 키와 서비스 롤 키를 나눈다. 슈퍼베이스, 포스트그레, Next.js, API, 백엔드, 보안, 카카오 로그인, 서버 액션, 개발자 기준. 2026년 9월 MDN, 포스트그레, 슈퍼베이스 공식 문서.

by

조회는 되고 저장만 403이면 로그인이 풀린 게 아니라, 조회 정책은 통과했는데 넣기 정책의 쓰기 검사가 그 행을 거절한 상태입니다. 화면 세션을 고치기 전에 조회 조건과 쓰기 검사, 요청에 실린 사용자 아이디부터 보세요.


목록은 뜨는데 글 저장만 막히는 일, 슈퍼베이스에 테이블을 열고 행 수준 보안을 켠 직후 자주 납니다. 제가 문의 글을 붙일 때도 공개 조회만 두고 넣기 정책을 빼먹으면 같은 403이 납니다.


만료 토큰 글과 자리는 다릅니다. 401은 자격이 없거나 만료된 쪽이고, 여기 403은 누구인지는 아는데 그 행을 쓸 권한이 없는 쪽입니다. 근거는 MDN 403슈퍼베이스 행 수준 보안, 포스트그레 정책 생성에 있습니다.


조회는 되고 저장만 막히면 어디를 보나

넣기 정책의 쓰기 검사부터 엽니다. 행 수준 보안이 켜진 테이블은 명령마다 정책이 따로입니다. 조회만 열어 두면 목록은 오고, 새 행을 넣는 순간 거절됩니다.


브라우저 네트워크 칸에 403이 찍히고, 본문에 42501이나 new row violates row-level security policy가 있으면 그 자리입니다. 포스트그레는 권한 부족을 그 코드로 내고, 슈퍼베이스 데이터 API는 HTTP 403으로 감싸 돌려 줍니다. 토큰이 없어서 거절되는 401과 메시지를 섞지 마세요. 401은 만료 토큰 글에서 다룹니다.


공개 조회에 로그인 넣기를 붙인 게시판이 전형적인 장면입니다. 조회 정책이 참이면 누구나 목록을 읽고, 넣기는 로그인된 역할과 자기 아이디 행만 통과합니다. 세션 쿠키는 살아 있는데 넣기만 막히면, 화면 로그인이 아니라 그 요청이 어떤 역할로 테이블에 들어갔는지를 보세요.


응답무엇을 말하나먼저 볼 칸
401자격이 없거나 만료됨Bearer, 액세스 토큰, 쿠키
403 + 42501누구인지는 아는데 그 행은 거절넣기 정책, 쓰기 검사, 테이블 권한
조회 빈 배열에러가 아니라 조회 조건이 행을 숨김조회 정책 USING
CORS 차단브라우저가 응답을 안 읽음프리플라이트 글

먼저 기억할 것 | 조회가 된다는 건 조회 정책과 조회 권한이 있다는 뜻입니다. 저장 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와 쓰기 검사가 있는지
5auth.uid와 user_id널이 아니고 서로 같은지
6넣기 뒤 조회조회 정책이 그 행을 보여 주는지

SQL 편집기에서 역할을 바꿔 넣기를 재현하면 앱 버그와 정책 버그가 갈립니다. 공식 흐름은 로컬 역할을 로그인된 역할로 두고, 토큰의 사용자 아이디를 맞춘 뒤 같은 insert를 실행하는 것입니다. 편집기에선 되고 앱만 막히면 앱이 익명 키로 보냈거나 컬럼을 뺀 겁니다.


1인으로 넥스트와 슈퍼베이스를 붙여 운영하면, 프리뷰 도메인의 리다이렉트와 로컬 세션이 갈려 이 증상이 잘 납니다. 쿠키가 한 브라우저에서만 풀리는 줄은 SameSite 글이고, 저장 403은 정책 칸입니다.


  • [ ] 네트워크 칸에서 403과 42501을 확인했다
  • [ ] 넣기 요청에 사용자 토큰이 실리는지 봤다
  • [ ] 테이블에 그 역할의 넣기 권한이 있는지 봤다
  • [ ] FOR INSERT 정책과 쓰기 검사가 있는지 봤다
  • [ ] user_id가 비지 않게 기본값 또는 본문을 맞췄다
  • [ ] 넣기 뒤 조회를 붙였다면 조회 정책도 짝으로 뒀다
  • [ ] 브라우저 번들에 서비스 롤 키가 없는지 봤다

참고 자료


내부 연계: 401 만료 토큰, 행 수준 보안 정책 심층, CORS 프리플라이트, 시크릿 환경변수, 소프트 삭제


인용한 동작은 2026년 9월 공개 문서 기준입니다.


자주 묻는 질문

로그인은 됐는데 저장만 403입니다. 토큰을 다시 받아야 하나요?

먼저 응답 본문의 코드를 보세요. 401이면 토큰 쪽입니다. 403과 42501이면 넣기 정책이나 테이블 넣기 권한입니다. 화면 세션이 살아 있어도 그 요청에 사용자 아이디가 안 실리면 익명으로 들어갑니다.


조회 정책을 자기 아이디로 열었는데 넣기가 막힙니다.

조회 정책은 넣기를 대신하지 않습니다. FOR INSERT와 쓰기 검사를 따로 만드세요. 들어오는 행의 사용자 아이디가 토큰 아이디와 같아야 통과합니다.


SQL 편집기에선 들어가는데 앱만 거절합니다.

편집기는 관리자 역할로 정책을 건너뛸 수 있습니다. 앱은 익명 키와 사용자 세션입니다. 편집기에서 로그인된 역할로 바꿔 같은 insert를 재현하세요.


서비스 롤 키를 프론트에 넣으면 403이 사라집니다. 그래도 되나요?

안 됩니다. 그 키는 행 수준 보안을 우회합니다. 브라우저에 두면 누구나 모든 행을 읽고 씁니다. 403은 넣기 정책과 권한으로 고치세요.


insert 뒤에 select를 붙이면 실패합니다. 행은 들어갔나요?

넣기 쓰기 검사는 통과하고 조회 조건이 그 행을 숨기면, 클라이언트는 실패로 읽습니다. 자기 행 조회 정책을 짝으로 두세요. 행이 남았는지는 서버 로그나 관리자 조회로 확인합니다.


카카오 로그인은 됐는데 슈퍼베이스 넣기만 막힙니다.

화면 로그인과 슈퍼베이스 세션은 다를 수 있습니다. 넣기 요청에 슈퍼베이스 액세스 토큰이 있는지, 그 토큰의 사용자 아이디가 테이블 칸과 같은지 보세요. 카카오 세션만 살아 있으면 요청은 익명입니다.


조회는 되고 저장만 403이면 넣기 정책의 쓰기 검사와 그 역할의 넣기 권한을 보면 됩니다. 조회 조건과 쓰기 검사를 명령별로 나누고, 사용자 아이디가 널이 아니게 맞추고, 서비스 롤 키는 서버에만 두세요. 관련 글: 401 만료 토큰, 정책 심층, CORS.


403ForbiddenRLS행 수준 보안슈퍼베이스권한INSERT포스트그레Next.js백엔드보안API개발자

함께 보면 좋은 문제 해결

EXPLORE / Backend

이어서 읽어보기

전체 토픽 둘러보기