Should I use Postgres Row-Level Security or application-level workspace checks?
It depends on how your application connects to Postgres. If browser or mobile clients query the database directly through PostgREST, Row-Level Security (RLS) is mandatory. If all traffic flows through a server-side TypeScript boundary, explicit membership checks on every query are your primary defense, and RLS serves as defense-in-depth against forgotten WHERE clauses. Combine live session-to-membership resolution with indexed workspace_id policies. supastack enforces server-side workspace guards across web, mobile, and desktop, and provides a free RLS policy generator for Supabase tables.
애플리케이션 가드와 데이터베이스 정책은 서로 다른 장애를 막아줍니다
개발자들은 종종 애플리케이션 수준 인가와 Postgres Row-Level Security(RLS)를 둘 중 하나만 선택하는 것으로 생각합니다. 실제로는 서로 다른 버그를 잡아냅니다. 서버 측 가드는 비즈니스 로직이 실행되기 전에 역할 계층, 기능 권한, 감사 로그를 강제하고, RLS는 WHERE workspace_id = ... 절을 빠뜨렸을 때 모든 테넌트의 행이 노출되는 사고를 원천 차단합니다.
Supabase를 사용하든 Drizzle과 자체 호스팅 Postgres를 사용하든, 테넌트가 소유한 모든 테이블에는 NULL이 아닌 workspace_id 외래 키가 있어야 합니다. 워크스페이스 경계 글에서 왜 이 범위를 요청 파라미터가 아닌 서버에서 직접 도출해야 하는지 설명합니다.
- 애플리케이션 가드는 세션 유효성, 멤버 역할(
owner,admin,member), 기능 권한을 검증합니다. - 데이터베이스 RLS 정책은 클라이언트 직접 쿼리나 원시 SQL 헬퍼를 사용할 때 최후의 방어선 역할을 합니다.
- 두 계층 모두 단일 진실 공급원인
workspace_members조인 테이블에 의존합니다.
오래된 쿠키를 신뢰하지 말고 실시간 멤버십을 확인하세요
브라우저 쿠키나 클라이언트 헤더에 currentWorkspaceId를 저장하면 탐색에는 편리하지만, 쿠키는 선호 설정일 뿐 권한이 아닙니다. 관리자가 팀원을 워크스페이스에서 제거하더라도 해당 사용자의 브라우저에는 이전 워크스페이스 쿠키가 그대로 남아 있습니다.
모든 요청에서 활성 세션을 workspace_members와 조인하세요. 멤버십 행이 사라졌다면 JWT나 세션 쿠키가 만료될 때까지 기다리지 말고 즉시 요청을 거부해야 합니다. 로그인 폼 너머의 인증 글에서 신원 확인이 이 검사로 어떻게 이어지는지 다룹니다.
export async function requireWorkspaceViewer(requestedWorkspaceId?: string) {
const session = await requireAuthenticatedSession();
const membership = await db.query.workspaceMembers.findFirst({
where: and(
eq(workspaceMembers.userId, session.user.id),
requestedWorkspaceId
? eq(workspaceMembers.workspaceId, requestedWorkspaceId)
: undefined,
),
with: { workspace: true },
});
if (!membership) throw new ForbiddenError("Not a member of this workspace");
return { user: session.user, workspace: membership.workspace, role: membership.role };
}빠르고 인덱스를 타는 Row-Level Security 정책 작성하기
모든 행마다 auth.uid()를 직접 호출하는 단순한 RLS 정책은 Postgres가 튜플마다 함수를 다시 계산하게 만듭니다. (select auth.uid())처럼 스칼라 서브쿼리로 감싸면 쿼리 플래너가 값을 initPlan으로 캐싱할 수 있고, (workspace_id, user_id) 복합 인덱스를 추가하면 멤버십 조회를 1밀리초 미만의 인덱스 스캔으로 유지할 수 있습니다.
무료 Supabase RLS 정책 생성기를 사용하면 반복적인 SQL을 직접 작성하지 않고도 멀티테넌트 테이블용 SELECT, INSERT, UPDATE, DELETE 정책을 즉시 생성할 수 있습니다.
CREATE INDEX IF NOT EXISTS idx_workspace_members_lookup
ON workspace_members (workspace_id, user_id);
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
CREATE POLICY "workspace_members_manage_documents"
ON documents FOR ALL TO authenticated
USING (
workspace_id IN (
SELECT wm.workspace_id FROM workspace_members wm
WHERE wm.user_id = (SELECT auth.uid())
)
)
WITH CHECK (
workspace_id IN (
SELECT wm.workspace_id FROM workspace_members wm
WHERE wm.user_id = (SELECT auth.uid())
AND wm.role IN ('owner', 'admin', 'member')
)
);RLS와 워크스페이스 인증에 대해 자주 묻는 질문
멀티테넌트 Postgres를 구축하는 팀들은 서비스 역할, 백그라운드 작업, 성능에서 동일한 엣지 케이스를 마주합니다.
- Drizzle ORM이나 Prisma는 RLS를 우회하나요? — 네, 서버가
postgres슈퍼유저나 Supabaseservice_role키로 연결하면 우회합니다. 따라서 서버 액션과 API 라우트는 반드시requireWorkspaceViewer()를 호출하고 모든 쿼리를workspaceId로 제한해야 합니다. - RLS 정책 안에서 왜
(select auth.uid())를 괄호로 감싸나요? — Postgres 옵티마이저가 스캔하는 행마다가 아니라 구문당 한 번만auth.uid()를 평가하도록 만들어 대규모 테이블에서 순차 스캔으로 인한 속도 저하를 막아줍니다. - 백그라운드 작업은 테넌트 데이터에 어떻게 접근해야 하나요? — 인증된 서버 라우트에서 검증된
workspaceId를 작업 페이로드에 담아 전달하고, 워커의 읽기와 쓰기를 해당workspaceId로 제한하세요. - 테넌트 격리가 실제로 유지되는지 어떻게 검증하나요? — 사용자 A가 웹, 모바일, API 라우트를 통해 사용자 B의 레코드 ID를 조회하거나 수정하려고 시도하는 자동화된 네거티브 테스트를 작성하고, 변경된 행 없이
403/404가 반환되는지 확인하세요.