Multi-Tenant Next.js 與 Supabase RLS:生產指南
為什麼選擇 Supabase RLS 進行多租戶架構
說實話,當涉及到在 SaaS 應用中處理多租戶時,你有很多選擇。你可以為每個租戶單獨設置一個數據庫,這聽起來像組織上的天堂,但成本高昂且管理起來非常痛苦。或者,你可以嘗試使用單獨的架構,這在操作上麻煩較少,但當涉及到遷移時仍然不是輕而易舉的事。但隨後出現了 SaaS 世界的寵兒——共享表加行級過濾。Supabase 通過其原生 PostgreSQL 行級安全性(Row Level Security,RLS)使這種方法變得輕而易舉。
為什麼這甚至重要?很簡單。你的數據過濾發生在數據庫級別。如果你在 Next.js API 路由中搞砸了 WHERE 子句,你不會為數據洩露而擔心失眠,因為數據庫本身就是你的安全網。而且說實話,在當今時代,這不是奢侈品——這是必需品。
但我們不要自欺欺人。RLS 為你的查詢增加開銷,使調試複雜化,並可能在遷移過程中絆你一跤。那麼,不同的多租戶方法如何比較呢?
| 方法 | 隔離級別 | 成本 | 操作複雜性 | 查詢性能 |
|---|---|---|---|---|
| 每個租戶一個數據庫 | 完全 | 高(50-200 美元/租戶/月) | 非常高 | 最佳 |
| 每個租戶一個架構 | 強 | 中等 | 高(遷移) | 良好 |
| 共享表 + RLS | 行級 | 低 | 中等 | 良好(有注意事項) |
| 應用級過濾 | 無 | 最低 | 低 | 最佳 |
對於租戶數少於 10,000 的大多數 SaaS 產品,共享表加 RLS 能以最少的成本獲得最大的回報。這正是我們在此深入探討的內容。

架構模式:共享與隔離
在甚至考慮編寫代碼之前,你必須選擇你的租戶解析策略。在現實中,你大多會遇到兩種方式:
基於子域的租戶模式
見過 tenant-slug.yourapp.com 嗎?歡迎來到 B2B SaaS 最常見的模式。它時尚、專業,並使中間件中的租戶解析變得輕而易舉。
基於路徑的租戶模式
這個就是基本的 /org/tenant-slug/dashboard。由於沒有通配符 DNS,設置起來更容易,並且在 Vercel 等平台上無需自定義域即可運行。但說實話:它感覺有點像穿著襪子和涼鞋。我們通常為生產 B2B 應用推薦基於子域的方式,為內部工具或 MVP 推薦基於路徑的方式。稍後切換?你會詛咒你的過去——改變這些模式絕非小事。
設置租戶架構
這是一個在三個不同生產部署中從未讓我們失望的架構模式:
-- 核心租戶表
CREATE TABLE organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
plan TEXT NOT NULL DEFAULT 'free',
created_at TIMESTAMPTZ DEFAULT now(),
settings JSONB DEFAULT '{}'
);
-- 成員關聯表
CREATE TABLE memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
org_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(user_id, org_id)
);
-- 示例租戶範圍表
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID REFERENCES organizations(id) ON DELETE CASCADE NOT NULL,
name TEXT NOT NULL,
description TEXT,
created_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ DEFAULT now()
);
-- 在 org_id 上建立索引 — 你需要在「每個」租戶範圍表上建立索引
CREATE INDEX idx_projects_org_id ON projects(org_id);
CREATE INDEX idx_memberships_user_id ON memberships(user_id);
CREATE INDEX idx_memberships_org_id ON memberships(org_id);
memberships 表是粘合劑,將一切結合在一起。所有的 RLS 策略都會指向它,就像它是它們最喜歡的堂表親一樣。用戶可以加入多個組織,他們的角色決定了他們能或不能做什麼。這裡有一點智慧的提示:總是——非常認真地,總是——在每個租戶範圍表上索引 org_id。否則,一旦你在數據中遨遊,就會看著你的查詢像糖漿一樣爬行。當一個客戶的儀表板從 50ms 下降到 8 秒,有 100,000 行時,我們曾經措手不及。教訓已吸取。
真正可擴展的 RLS 策略
這是教程通常退出的地方,讓你陷入困境。他們向你扔出 auth.uid() = user_id 並說,「祝你好運!」但多租戶 RLS 不能這樣簡化。
幫助函數模式
為什麼要在每個策略中都混亂地進行成員檢查?改用幫助函數:
-- 幫助函數:檢查當前用戶是否是組織成員
CREATE OR REPLACE FUNCTION public.is_member_of(org UUID)
RETURNS BOOLEAN AS $$
SELECT EXISTS (
SELECT 1 FROM memberships
WHERE user_id = auth.uid()
AND org_id = org
);
$$ LANGUAGE sql SECURITY DEFINER STABLE;
-- 幫助函數:獲取用戶在組織中的角色
CREATE OR REPLACE FUNCTION public.get_role_in(org UUID)
RETURNS TEXT AS $$
SELECT role FROM memberships
WHERE user_id = auth.uid()
AND org_id = org
LIMIT 1;
$$ LANGUAGE sql SECURITY DEFINER STABLE;
為什麼是 SECURITY DEFINER?因為該函數以創建者的權限運行,跳過 memberships 表上的 RLS。沒有這個,你會陷入一個循環依賴的兔子洞,其中 memberships 上的 RLS 會崩潰其他表依賴的成員資格檢查。
而 STABLE 部分呢?它向查詢規劃器發出信號,在單個查詢期間,函數的輸出對於相同的輸入保持一致,這能夠啟用一些很好的緩存優勢。想用 IMMUTABLE 嗎?別這樣。成員資格可以在事務之間翻轉。
租戶範圍表的策略
讓我們看一下 projects 表的一些策略:
-- 啟用 RLS
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- SELECT:成員可以查看他們組織中的項目
CREATE POLICY "Members can view org projects"
ON projects FOR SELECT
USING (public.is_member_of(org_id));
-- INSERT:管理員和所有者可以創建項目
CREATE POLICY "Admins can create projects"
ON projects FOR INSERT
WITH CHECK (
public.get_role_in(org_id) IN ('owner', 'admin')
);
-- UPDATE:管理員和所有者可以更新項目
CREATE POLICY "Admins can update projects"
ON projects FOR UPDATE
USING (public.is_member_of(org_id))
WITH CHECK (
public.get_role_in(org_id) IN ('owner', 'admin')
);
-- DELETE:只有所有者可以刪除項目
CREATE POLICY "Owners can delete projects"
ON projects FOR DELETE
USING (
public.get_role_in(org_id) = 'owner'
);
成員資格表本身的策略
這個很棘手。memberships 表有自己的 RLS,但它不能使用幫助函數,因為它們反過來查詢 memberships——引發循環參考噩夢:
ALTER TABLE memberships ENABLE ROW LEVEL SECURITY;
-- 用戶可以看到他們所屬組織中的成員資格
CREATE POLICY "Users can view org memberships"
ON memberships FOR SELECT
USING (
org_id IN (
SELECT org_id FROM memberships WHERE user_id = auth.uid()
)
);
-- 只有所有者可以添加成員
CREATE POLICY "Owners can add members"
ON memberships FOR INSERT
WITH CHECK (
org_id IN (
SELECT org_id FROM memberships
WHERE user_id = auth.uid() AND role = 'owner'
)
);
是的,同一個表上有一個子查詢。是的,PostgreSQL 能夠處理它。子查詢檢查你自己的成員資格,不受所定義策略的影響,因為 RLS 只圍繞外部查詢進行。但要測試這個——認真地說,你不想在生產中發現一個錯誤。

Next.js 中間件進行租戶解析
使用 Next.js 15 和時尚的 App Router,在邊緣運行的中間件是租戶解析的完美房東。這是我們對基於子域設置的可信模式:
// middleware.ts
import { createServerClient } from '@supabase/ssr';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const PUBLIC_ROUTES = ['/login', '/signup', '/invite'];
export async function middleware(request: NextRequest) {
const hostname = request.headers.get('host') || '';
const currentHost = hostname.split('.')[0];
// 為主域和本地主機跳過
const isMainDomain = currentHost === 'app' || currentHost === 'www' || currentHost === 'localhost:3000';
let response = NextResponse.next({
request: { headers: request.headers },
});
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => {
request.cookies.set(name, value);
response.cookies.set(name, value, options);
});
},
},
}
);
const { data: { user } } = await supabase.auth.getUser();
if (!isMainDomain) {
response.headers.set('x-tenant-slug', currentHost);
if (!user && !PUBLIC_ROUTES.some(r => request.nextUrl.pathname.startsWith(r))) {
return NextResponse.redirect(new URL('/login', request.url));
}
}
return response;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|api/webhooks).*)'],
};
x-tenant-slug 標頭純金。使用它讓你的服務器組件和 API 路由知道他們在處理哪個租戶。如果你在與我們合作 Next.js 項目,設置這個是我們的第一天優先事項。
多租戶應用中的身份驗證流
Supabase Auth 在多租戶遊戲中保持中立。用戶存在於全局領域——租戶關係是你要解決的謎題。這是我們的遊戲計劃:
- 用戶註冊: 創建一個身份驗證用戶,構建一個組織,並用「owner」角色創建一個成員資格。
- 用戶被邀請: 管理員制定待處理邀請,新用戶通過邀請鏈接加入,然後出現——用指定角色的成員資格。
- 用戶登錄: 從子域提取租戶,確認成員資格,護送他們進入他們的儀表板。
// app/api/auth/signup/route.ts
import { createClient } from '@/lib/supabase/server';
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
const { email, password, orgName, orgSlug } = await request.json();
const supabase = await createClient();
// 註冊用戶
const { data: authData, error: authError } = await supabase.auth.signUp({
email,
password,
});
if (authError) return NextResponse.json({ error: authError.message }, { status: 400 });
// 使用服務角色客戶端進行組織創建(繞過 RLS)
const adminClient = createAdminClient();
const { data: org, error: orgError } = await adminClient
.from('organizations')
.insert({ name: orgName, slug: orgSlug })
.select()
.single();
if (orgError) return NextResponse.json({ error: orgError.message }, { status: 400 });
// 創建所有者成員資格
await adminClient
.from('memberships')
.insert({
user_id: authData.user!.id,
org_id: org.id,
role: 'owner',
});
return NextResponse.json({ org });
}
請注意,我們在註冊期間依賴服務角色客戶端。用戶還沒有任何成員資格,所以 RLS 會讓他們在組織創建時陷入困境。這是那些經典的引導問題之一——你的服務角色金鑰將是你的魔杖。
我再怎麼強調也不為過:永遠,永遠不要將你的服務角色金鑰暴露給客戶端。 它嚴格用於服務器端代碼。
服務器組件和 RLS:SSR 問題
Next.js 15 的服務器組件是服務器綁定的,提升了安全遊戲。但在使用 Supabase RLS 時存在一個小故障:你必須向 Supabase 客戶端提供用戶的會話,以便 RLS 策略知道誰在表上。
// lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// 這在服務器組件中可能失敗(只讀)
// 中間件處理 cookie 刷新
}
},
},
}
);
}
// app/[orgSlug]/projects/page.tsx
import { createClient } from '@/lib/supabase/server';
import { headers } from 'next/headers';
export default async function ProjectsPage() {
const supabase = await createClient();
const headersList = await headers();
const tenantSlug = headersList.get('x-tenant-slug');
// 從 slug 獲取組織 ID
const { data: org } = await supabase
.from('organizations')
.select('id')
.eq('slug', tenantSlug)
.single();
if (!org) return <div>Organization not found</div>;
// RLS 自動過濾 — 只返回項目
// 當前用戶具有成員資格的地方
const { data: projects } = await supabase
.from('projects')
.select('*')
.eq('org_id', org.id)
.order('created_at', { ascending: false });
return (
<div>
{projects?.map(project => (
<ProjectCard key={project.id} project={project} />
))}
</div>
);
}
這是關鍵:即使有人篡改請求中的 org_id,RLS 也不會動搖。它阻止訪問項目,除非用戶是成員。從技術上講,.eq('org_id', org.id) 對於安全性是冗餘的——RLS 處理它——但對於性能和可讀性很有好處。
性能優化和常見缺陷
N+1 RLS 查詢問題
每個 RLS 策略檢查都會啟動一個子查詢。當你盯著 100 行時,連接到一個 10 行 策略檢查意味著進行 100 輪成員資格查找。幸運的是,PostgreSQL 足夠聰明可以緩存——但存在開銷。
修復: 在幫助函數上使用 STABLE(如我們所概述的)。另外,考慮將 org_id 反規範化到 JWT 聲明中:
-- 自定義 JWT 鉤子(Supabase 儀表板 > 身份驗證 > 鉤子)
CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event JSONB)
RETURNS JSONB AS $$
DECLARE
org_ids UUID[];
BEGIN
SELECT array_agg(org_id) INTO org_ids
FROM memberships
WHERE user_id = (event->>'user_id')::UUID;
event := jsonb_set(
event,
'{claims,org_ids}',
to_jsonb(org_ids)
);
RETURN event;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
然後你的 RLS 策略變成:
CREATE POLICY "Members can view"
ON projects FOR SELECT
USING (
org_id = ANY(
(SELECT array(SELECT jsonb_array_elements_text(
auth.jwt()->'org_ids'
))::UUID[])
)
);
這完全消除了成員資格表的查找。組織 ID 直接來自 JWT。注意: JWT 聲明在登錄時被蓋章。改變某人的成員資格,他們需要重新身份驗證以同步聲明。通常,這完全是可管理的——只需將其保留在你的文檔中。
連接池
Supabase 通過 PgBouncer 提供連接池。如果你要在 Vercel 上實時運行 Next.js,請記住:為 API 路由和服務器組件使用池 URL。
# 用於常規操作(池)
DATABASE_URL=postgres://user:pass@db.project.supabase.co:6543/postgres
# 僅用於遷移(直接)
DIRECT_URL=postgres://user:pass@db.project.supabase.co:5432/postgres
Supabase 的 Pro(每月 25 美元)上的任何人都通過池獲得 200 個併發連接。對於大多數同時少於 1000 用戶的 SaaS 應用,綽綽有餘。
你絕對需要的索引
這是多租戶設置的蠻力索引集:
-- 在每個租戶範圍表上
CREATE INDEX idx_{table}_org_id ON {table}(org_id);
-- 複合索引用於常見查詢
CREATE INDEX idx_projects_org_created ON projects(org_id, created_at DESC);
-- 成員資格 — 通過 RLS 大量查詢
CREATE INDEX idx_memberships_user_org ON memberships(user_id, org_id);
CREATE INDEX idx_memberships_org_role ON memberships(org_id, role);
EXPLAIN ANALYZE——開發人員最好的朋友。看看你的查詢如何與 RLS 一起進行。你可能會對規劃器決定做什麼、沒有正確的索引感到驚訝。
測試 RLS 策略
每個人都跳過這個,但這是你防止數據洩露的最好安全網。我們直接在 SQL 中測試 RLS 策略:
-- 以特定用戶身份測試
SET request.jwt.claims = '{"sub": "user-uuid-here", "role": "authenticated"}';
SET role = 'authenticated';
-- 這應該只返回用戶可以訪問的項目
SELECT * FROM projects;
-- 這應該失敗(用戶不是這個組織的成員)
INSERT INTO projects (org_id, name) VALUES ('other-org-uuid', 'Sneaky Project');
-- 重置
RESET role;
別忘了 pgTAP 用於關鍵策略:
BEGIN;
SELECT plan(3);
-- 設置測試上下文作為用戶 A(組織 1 的成員)
SET LOCAL request.jwt.claims = '{"sub": "user-a-uuid"}';
SET LOCAL role = 'authenticated';
SELECT is(
(SELECT count(*) FROM projects WHERE org_id = 'org-1-uuid')::INTEGER,
5,
'User A sees 5 projects in their org'
);
SELECT is(
(SELECT count(*) FROM projects WHERE org_id = 'org-2-uuid')::INTEGER,
0,
'User A sees 0 projects in other org'
);
SELECT throws_ok(
$$INSERT INTO projects (org_id, name) VALUES ('org-2-uuid', 'Hack')$$,
'new row violates row-level security policy',
'User A cannot insert into other org'
);
SELECT * FROM finish();
ROLLBACK;
在 CI 中運行這些。每個涉及 RLS 策略的遷移應該通過完整的測試套件進行劇烈鍛煉。
生產部署檢查清單
準備好發貨了嗎?用這個武裝自己:
- RLS 在「每個」包含租戶數據的表上啟用
- 服務角色金鑰秘密保管在服務器端,絕不在客戶端
-
org_id在所有租戶範圍表上都正確索引 - 成員資格幫助函數被授予
SECURITY DEFINER和STABLE - JWT 自定義聲明已上線(如果在 JWT 路線上)
- 為雲部署設置了連接池?
- RLS 策略通過 pgTAP 或其類似項的 QA 測試
- 用 RLS 運行的關鍵查詢上已經啟動
EXPLAIN ANALYZE - 邀請/註冊流是否缺少任何成員資格引導?
- 在身份驗證端點上是否設置了速率限制?Supabase 提供內置選項
- 在 Supabase 儀表板中翻轉了
auth架構表上的 RLS(通常是地雷) - 為任何慢查詢嵌入監控(Supabase 儀表板 > 數據庫 > 查詢性能)
正在推出多租戶產品並想要一個曾經涉足過這些水域的人嗎?我們的 無頭 CMS 開發解決方案 或通過我們的 聯繫頁面 進行快速聊天可能正是你所需要的。
常見問題
我可以為擁有數千個租戶的應用使用 Supabase RLS 嗎?
絕對可以。我們已經在擁有 5,000 多個租戶和數百萬行的共享表 RLS 上進行了試驗,沒有出汗。秘密醬料是什麼?在 org_id 列上進行適當的索引和 STABLE 幫助函數。考慮 50,000 多個租戶或十億行盛宴?深入研究按 org_id 分區表或尋求每個架構租戶設置。
當用戶屬於多個組織時,我如何處理租戶切換? 將活動組織放在 Cookie 或 URL(子域)中。交換組織?修改子域/cookie 並重新獲取。別把活動組織塞進 JWT 中——它需要重新登錄才能更改。你的中間件可以偷看的 cookie 是要走的路。
如果我忘記在表上啟用 RLS 會發生什麼?
每個經過身份驗證的用戶都可以點擊每一行。這是 PostgreSQL 的默認立場——沒有 RLS 的表上沒有行級限制。Supabase 儀表板標記缺少 RLS 的表,但將其嵌入到 CI 中並使用針對 pg_tables 和 pg_policies 的查詢會有所幫助。
我應該使用 Supabase 的服務角色金鑰還是為管理職責烹飪自定義 PostgreSQL 角色? 大多數情況下,服務角色金鑰就足夠了。它完全繞過 RLS,所以它只能供服務器端使用。需要粒度治理(比如一個「admin」角色在所有組織中潛伏但避免刪除)?那是自定義 PostgreSQL 領土——高級,通常在複雜的內部工具需要它時才會出現在你的雷達上。
我如何運行數據庫遷移而不被 RLS 策略絆倒?
Supabase 的 CLI(supabase db push 或 supabase migration)以及直接數據庫 URL(跳過池)會幫你。將 RLS 策略編輯與架構調整塞進同一個遷移中。針對暫存項目測試演播轉遷——Supabase 讓你在 Pro 上旋轉預覽分支,僅用於此目的。
RLS 策略可以到達來自其他 API 或服務的數據嗎?
不行。RLS 策略安全地坐在 SQL 中,由 PostgreSQL 評估。渴望檢查外部數據(比如功能標誌服務)?將該數據水泥化到數據庫表中,然後在策略中引用。一個典型的模式是從 Stripe 同步訂閱狀態到 organizations.plan 列。
與應用層過濾相比,RLS 的性能稅是多少? 在我們的 Supabase Pro 基準測試(2 vCPU,8GB RAM)上,對於具有正確索引的基本成員資格檢查策略,RLS 為每個查詢增加 1-3ms。盡情享受策略複雜性或連接,你可能會添加 5-15ms。JWT 聲明策略(在令牌中存儲 org_ids)由於沒有子查詢舞蹈,將其切成低於 1ms。對於典型的網絡應用,延遲涓涓細流是無關緊要的。
這如何與 Supabase Realtime 訂閱一起工作?
Supabase Realtime 遵循 RLS 規則簿。根據 RLS 調整到表更改並只捕捉你有資格看到的行的事件。這開箱即用,零額外調整。只要確保你的客戶端 Supabase 有用戶會話,@supabase/ssr 可以無縫處理。