Supabase Auth 深度配置:OAuth、SSO 与权限控制
那天下午,销售同事跑过来跟我说:“客户要求用 Okta 登录,两周内要上线 SSO。“我当时愣了一下——我的应用只支持邮箱注册和 Google OAuth,SAML?完全没接触过。
说实话,这种场景在 B2B SaaS 里太常见了。企业客户不会接受让你的员工单独注册账号,他们有统一的身份管理系统(Okta、Azure AD、Google Workspace),登录要一键跳转,员工离职账号自动失效。
这篇文章就是为这种场景准备的。我们会从 OAuth 社交登录讲起,过渡到 SAML SSO 企业集成,最后用 Row Level Security(RLS)实现多租户权限隔离——一套完整的认证与授权方案,从消费者级到企业级都能覆盖。
一、OAuth 多 Provider 配置实战
OAuth 社交登录是大多数应用的起点。用户不想记密码,你也不想处理密码存储和验证——让 Google 或 GitHub 来做这件事,双方都省心。
Supabase 支持的 OAuth Provider 很多:Google、GitHub、Apple、Facebook、Discord、Twitter……但实际生产环境里,Google 和 GitHub 使用最广,Apple 则是 iOS 应用的硬性要求(App Store 审核需要)。我们先从 Google 开始。
Google OAuth 配置
打开 Google Cloud Console,那密密麻麻的菜单有时候真让人头大。别慌,直接搜 “OAuth Client ID” 就能找到入口。
创建时选 Web application 类型,关键一步是填写 Authorized redirect URI:
https://<你的项目ref>.supabase.co/auth/v1/callback
这个地址是 Supabase Auth 服务接收 OAuth 回调的地方。项目 ref 在 Supabase Dashboard 左上角能看到,形如 abcdefghijklmnop。
拿到 Client ID 和 Client Secret 后,回到 Supabase Dashboard,找到 Authentication > Providers,启用 Google,填入这两个值。
代码调用就简单了:
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
'https://abcdefghijklmnop.supabase.co',
'your-anon-key'
)
// 发起 OAuth 登录
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: 'https://your-app.com/auth/callback',
scopes: 'email profile'
}
})
if (error) {
console.error('登录失败:', error.message)
return
}
// data.url 是跳转地址,直接 window.location.href = data.url 即可
redirectTo 是你的应用接收登录结果的地址,Supabase 会把 code 和 state 参数传过去。你需要在那个页面调用 supabase.auth.exchangeCodeForSession() 完成登录:
// 在 /auth/callback 页面
const { error } = await supabase.auth.exchangeCodeForSession()
if (!error) {
// 登录成功,跳转到主页
window.location.href = '/'
}
GitHub OAuth 配置
GitHub 的配置类似,入口在 Settings > Developer settings > OAuth Apps。创建时填的 Authorization callback URL 同样是 https://<项目ref>.supabase.co/auth/v1/callback。
有一点区别:GitHub OAuth App 没有 scopes 配置界面,scopes 在代码里指定:
await supabase.auth.signInWithOAuth({
provider: 'github',
options: {
redirectTo: 'https://your-app.com/auth/callback',
scopes: 'repo user' // repo 可以访问私有仓库
}
})
如果你只是做用户登录,用默认 scopes 就够了。如果需要访问用户的 GitHub 数据(比如同步仓库列表),scopes 就得加上 repo。
Apple OAuth 配置
Apple 的配置最麻烦。要在 Apple Developer Portal 创建 Services ID,还要生成私钥(.p8 文件),私钥只能下载一次,丢了就得重新生成。
关键参数:
- Services ID:类似 Client ID
- Team ID:在 Membership 页面找
- Key ID:私钥的 ID
- Private Key:下载的 .p8 文件内容
Supabase Dashboard 里填这些值时,Private Key 要把文件内容完整复制进去(包括 BEGIN/END 那两行)。
调用代码跟 Google/GitHub 一样:
await supabase.auth.signInWithOAuth({
provider: 'apple',
options: {
redirectTo: 'https://your-app.com/auth/callback'
}
})
iOS 应用还有一种方式:直接调用原生 Sign in with Apple API,拿到 identity token 后传给 Supabase:
// 前端收到 Apple 返回的 identity_token
const { data, error } = await supabase.auth.signInWithIdTokenCredentials({
provider: 'apple',
token: identityToken
})
这种方式适合已经集成了原生 Apple 登录的 iOS app,可以复用现有逻辑。
二、SAML SSO 企业集成
回到开头那个场景:客户要求用 Okta 登录。这时候 OAuth 就不够用了,需要 SAML 2.0 SSO。
SAML 的工作方式和 OAuth 完全不同。OAuth 是用户授权第三方应用访问自己的数据,而 SAML 是企业身份提供商(IdP)向你的应用(ServiceProvider, SP)发送用户身份信息。对企业来说,SAML 更可控——用户离职,IdP 禁掉账号,所有接入的应用都自动失效。
配置前的准备工作
你需要从客户那里拿到 IdP 的 Metadata。这个文件包含了 IdP 的证书、端点地址等信息。Okta、Azure AD、Google Workspace 都有导出 Metadata 的入口。
Supabase 提供的关键 URL:
EntityID(SP 标识):
https://<项目ref>.supabase.co/auth/v1/sso/saml/metadata
ACS URL(接收 SAML Response 的地址):
https://<项目ref>.supabase.co/auth/v1/sso/saml/acs
Metadata URL(可下载):
https://<项目ref>.supabase.co/auth/v1/sso/saml/metadata?download=true
把这几个 URL 告诉客户的 IT 管理员,让他们在 Okta/Azure AD 里创建 SAML 应用。
使用 Supabase CLI 配置 SSO
Supabase Dashboard 现在也支持 SAML 配置,但我更习惯用 CLI——毕竟企业客户的配置可能要反复调试,命令行更可控。
# 添加 SAML 连接
supabase sso add --type saml --project-ref <项目ref> \
--metadata-url 'https://company.okta.com/app/exk123/saml/samlmetadata' \
--domains company.com
--domains 参数很重要。它告诉 Supabase:所有 company.com 域名的用户登录时,应该走这个 SAML 连接。
你也可以用 --metadata-file 代替 --metadata-url,直接上传 XML 文件:
supabase sso add --type saml --project-ref <项目ref> \
--metadata-file ./okta-metadata.xml \
--domains company.com
命令执行后会返回一个 sso_provider_id,类似 abc123def456。这个 ID 是 RLS 权限隔离的关键。
Attribute Mapping 配置
SAML Response 里包含用户信息(邮箱、姓名、部门等),但字段命名可能不统一。你需要告诉 Supabase 如何映射这些字段。
创建一个 JSON 文件:
{
"keys": {
"email": {
"name": "email",
"names": ["EmailAddress", "email", "mail"],
"required": true
},
"first_name": {
"name": "first_name",
"names": ["FirstName", "givenName", "first_name"]
},
"last_name": {
"name": "last_name",
"names": ["LastName", "surname", "last_name"]
}
}
}
然后用 supabase sso update 应用这个配置:
supabase sso update <sso_provider_id> \
--project-ref <项目ref> \
--attribute-mapping-file ./mapping.json
多租户 SSO 配置
一个项目可以添加多个 SAML 连接。比如你有两个企业客户,一个是 Acme Corp(用 Okta),一个是 Globex Inc(用 Azure AD):
# 添加 Acme Corp 的 SSO
supabase sso add --type saml --project-ref <项目ref> \
--metadata-url 'https://acme.okta.com/.../metadata' \
--domains acme.com
# 返回 sso_provider_id: provider_abc
# 添加 Globex Inc 的 SSO
supabase sso add --type saml --project-ref <项目ref> \
--metadata-url 'https://globex.azure.com/.../metadata' \
--domains globex.com
# 返回 sso_provider_id: provider_def
现在 Acme Corp 的员工登录时会走 provider_abc,Globex Inc 的员工走 provider_def。每个 SSO 连接的用户数据是隔离的。
用户登录体验
配置完成后,用户的登录流程是这样的:
- 用户输入邮箱(比如
[email protected]) - Supabase 检测到
acme.com域名有 SSO 配置 - 自动跳转到 Acme 的 Okta 登录页面
- 用户在 Okta 输入账号密码(可能已经登录过了,直接通过)
- Okta 发送 SAML Response 到 Supabase
- Supabase 验证后创建 session,跳回你的应用
代码端发起 SSO 登录:
// 用户输入邮箱后,调用这个方法
const { data, error } = await supabase.auth.signInWithSSO({
domain: 'acme.com'
})
if (data?.url) {
// 跳转到 SSO 登录页面
window.location.href = data.url
}
也可以直接用 sso_provider_id:
const { data, error } = await supabase.auth.signInWithSSO({
providerId: 'provider_abc'
})
SSO 用户的特点
SSO 登录创建的用户和普通用户不同:
- 邮箱由 IdP 管理:用户不能在应用里改邮箱
- 没有密码:密码存储在 IdP,Supabase 不参与验证
- 邮箱不可验证:因为 IdP 已经验证过了
这意味着如果用户需要改邮箱,得让客户 IT 管理员在 Okta/Azure AD 里操作。
三、Row Level Security 进阶用法
认证解决了”用户是谁”的问题,授权解决”用户能做什么”的问题。
传统做法是在业务代码里检查权限:每个 API 都要判断当前用户能否访问这条数据。代码重复,容易遗漏,而且性能不好——每次请求都要查询数据库判断权限。
PostgreSQL 的 Row Level Security(RLS)把权限检查下沉到数据库层。每个查询自动带上权限过滤,不需要业务代码操心。
RLS 启用后的默认行为
这一条很多人踩坑:启用 RLS 后,默认拒绝所有访问。
-- 启用 RLS
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- 此时任何查询都返回空(包括管理员)
SELECT * FROM posts; -- 返回 0 行
必须创建 Policy 才能允许访问。
Policy 的两个关键部分
Policy 有两个 clause:USING 和 WITH CHECK。
USING clause:判断用户能否”看到”或”定位”这条数据。用于 SELECT、UPDATE、DELETE 操作——用户必须先能看到数据,才能修改或删除。
WITH CHECK clause:判断用户能否”写入”这条数据。用于 INSERT、UPDATE 操作——写入后的数据必须满足条件。
-- 用户只能操作自己的 posts
CREATE POLICY "Users manage own posts" ON posts
FOR ALL TO authenticated
USING (auth.uid() = author_id)
WITH CHECK (auth.uid() = author_id);
-- 只允许查看(不能修改)
CREATE POLICY "Users view own posts" ON posts
FOR SELECT TO authenticated
USING (auth.uid() = author_id);
-- 只允许插入(不能查看别人的)
CREATE POLICY "Users insert posts" ON posts
FOR INSERT TO authenticated
WITH CHECK (auth.uid() = author_id);
auth.uid() 返回当前登录用户的 UUID。未登录返回 null,所以 TO authenticated 确保 Policy 只对登录用户生效。
RESTRICTIVE vs PERMISSIVE
一个表可以有多个 Policy。默认是 PERMISSIVE 模式——满足任一 Policy 即可访问。
-- Policy 1:用户能看到自己的
CREATE POLICY "Own data" ON posts
FOR SELECT USING (auth.uid() = author_id);
-- Policy 2:公开的帖子所有人能看到
CREATE POLICY "Public posts" ON posts
FOR SELECT USING (is_public = true);
-- 两个 PERMISSIVE Policy:满足任一即可
RESTRICTIVE Policy 则是”必须满足”。它会和 PERMISSIVE Policy 组合使用,形成更严格的限制。
-- 所有访问必须满足这个条件(作为"全局过滤器")
CREATE POLICY "Tenant isolation" ON posts
AS RESTRICTIVE TO authenticated
USING (tenant_id = (
SELECT tenant_id FROM users WHERE id = auth.uid()
));
-- 再加上 PERMISSIVE Policy 控制具体操作
CREATE POLICY "Authors edit own posts" ON posts
FOR UPDATE USING (auth.uid() = author_id);
这个组合的效果:用户必须属于正确的租户(RESTRICTIVE),并且是作者才能编辑(PERMISSIVE)。
多租户隔离完整模式
假设你有一个 SaaS 应用,每个企业客户是一个租户。用户表有 tenant_id,所有业务表都要按租户隔离。
-- 用户表
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
email TEXT,
sso_provider_id TEXT -- SSO 用户才有这个
);
-- 业务表
CREATE TABLE projects (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
name TEXT,
created_by UUID REFERENCES users(id)
);
-- 启用 RLS
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- 全局租户隔离(RESTRICTIVE)
CREATE POLICY "Tenant isolation" ON projects
AS RESTRICTIVE TO authenticated
USING (tenant_id = (
SELECT tenant_id FROM users WHERE id = auth.uid()
));
-- 具体操作权限(PERMISSIVE)
CREATE POLICY "Tenant users can view" ON projects
FOR SELECT TO authenticated
USING (true); -- 已经被 RESTRICTIVE 过滤了,这里直接放行
CREATE POLICY "Project creators can edit" ON projects
FOR UPDATE TO authenticated
USING (created_by = auth.uid());
这样配置后,每个查询自动带上租户过滤,即使业务代码写了 SELECT * FROM projects,数据库也只返回当前租户的数据。
SSO 用户的多租户隔离
SSO 登录的用户可以用 sso_provider_id 来隔离。JWT 里有一个字段存储了登录方式:
-- RLS Policy for SSO 用户
CREATE POLICY "SSO tenant isolation" ON organization_settings
AS RESTRICTIVE TO authenticated
USING (sso_provider_id = (
SELECT auth.jwt()#>>'{amr,0,provider}'
));
auth.jwt()#>>'{amr,0,provider}' 从 JWT 里提取登录方式的 provider ID。SSO 登录时这个值就是 sso_provider_id。
性能优化要点
RLS Policy 是隐式的 WHERE clause。每个查询都会自动加上,而且可能包含子查询。这会影响性能。
几个优化建议:
1. 给 Policy 里的字段建索引
CREATE INDEX idx_posts_author ON posts(author_id);
CREATE INDEX idx_projects_tenant ON projects(tenant_id);
2. 避免 Policy 里的子查询
子查询在每行数据上都执行一次,开销很大。更好的做法是在 JWT 里存储 tenant_id,直接用 auth.jwt() 提取:
-- 不推荐:子查询
USING (tenant_id = (SELECT tenant_id FROM users WHERE id = auth.uid()))
-- 推荐:JWT 里存储 tenant_id
USING (tenant_id = (auth.jwt()->>'tenant_id')::uuid)
后面我们会讲如何用 Custom Access Token Hook 把 tenant_id 放进 JWT。
3. 用 SECURITY DEFINER 函数封装复杂逻辑
Policy 里的复杂逻辑可以封装成函数,函数设为 SECURITY DEFINER,避免每次重复执行:
CREATE FUNCTION current_tenant_id() RETURNS UUID
LANGUAGE SQL STABLE SECURITY DEFINER AS $$
SELECT tenant_id FROM users WHERE id = auth.uid();
$$;
-- Policy 里调用函数
CREATE POLICY "Tenant isolation" ON projects
AS RESTRICTIVE USING (tenant_id = current_tenant_id());
STABLE 表示函数在同一事务里返回值不变,PostgreSQL 会优化成只执行一次。
四、Custom Claims 与 RBAC 实现
JWT 里默认只有 Supabase 内置的字段:用户 ID、邮箱、角色(authenticated/anon)等。但很多时候你需要更多信息——用户角色(admin/moderator)、租户 ID、权限列表。
Supabase 提供 Custom Access Token Hook,让你在 JWT 发出前修改它的内容。
为什么需要 Custom Claims
几个典型场景:
- RBAC 权限控制:JWT 里存储用户角色,RLS Policy 根据角色判断权限
- 多租户隔离:JWT 里存储 tenant_id,避免 Policy 里的子查询
- 精简 JWT 大小:默认 JWT 包含很多字段,SSR 场景传输开销大,可以删掉不必要的
Supabase 的 JWT 默认字段比较多(session_id、aal、amr 等),每个请求都要带上。如果你的应用有大量 SSR 页面,JWT 在 cookie 里传输,体积会影响性能。
Custom Access Token Hook 实现
Hook 是一个 PL/pgSQL 函数,在 JWT 生成前执行。你可以添加、修改、删除 claims。
-- 创建 Hook 函数
CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event jsonb)
RETURNS jsonb
LANGUAGE plpgsql
AS $$
DECLARE
claims jsonb;
user_role text;
tenant_id uuid;
BEGIN
-- 获取 event 中的 claims
claims := event->'claims';
-- 从 user_roles 表获取用户角色
SELECT role INTO user_role
FROM public.user_roles
WHERE user_id = (event->>'user_id')::uuid;
-- 如果用户有角色,添加到 claims
IF user_role IS NOT NULL THEN
claims := jsonb_set(claims, '{user_role}', to_jsonb(user_role));
END IF;
-- 从 users 表获取 tenant_id
SELECT tenant_id INTO tenant_id
FROM public.users
WHERE id = (event->>'user_id')::uuid;
IF tenant_id IS NOT NULL THEN
claims := jsonb_set(claims, '{tenant_id}', to_jsonb(tenant_id));
END IF;
-- 返回修改后的 claims
RETURN jsonb_build_object('claims', claims);
END;
$$;
-- 授权 supabase_auth_admin 执行这个函数
GRANT EXECUTE ON FUNCTION public.custom_access_token_hook(jsonb)
TO supabase_auth_admin;
-- 在 Supabase Dashboard 启用这个 Hook
-- Authentication > Hooks > Custom Access Token > 选择上面这个函数
Hook 的输入参数 event 包含:
user_id:当前用户 UUIDclaims:当前的 JWT claimsauthentication_method:登录方式(password、oauth、sso/saml 等)
返回的 claims 会合并到最终 JWT 里。
RBAC 表结构设计
完整的角色权限系统需要几张表:
-- 定义角色类型
CREATE TYPE app_role AS ENUM ('admin', 'moderator', 'user');
-- 定义权限类型
CREATE TYPE app_permission AS ENUM (
'posts.delete', -- 删除任意帖子
'posts.pin', -- 置顶帖子
'users.manage', -- 管理用户
'settings.edit' -- 编辑设置
);
-- 用户-角色表
CREATE TABLE public.user_roles (
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
role app_role NOT NULL,
PRIMARY KEY (user_id, role)
);
-- 角色-权限表
CREATE TABLE public.role_permissions (
role app_role NOT NULL,
permission app_permission NOT NULL,
PRIMARY KEY (role, permission)
);
-- 插入默认权限
INSERT INTO role_permissions (role, permission) VALUES
('admin', 'posts.delete'),
('admin', 'posts.pin'),
('admin', 'users.manage'),
('admin', 'settings.edit'),
('moderator', 'posts.delete'),
('moderator', 'posts.pin');
authorize() 权限检查函数
有了角色和权限表,还需要一个函数来检查用户是否有某个权限:
CREATE OR REPLACE FUNCTION public.authorize(
requested_permission app_permission
)
RETURNS boolean
LANGUAGE plpgsql
STABLE SECURITY DEFINER
AS $$
DECLARE
user_role app_role;
BEGIN
-- 从 JWT 获取用户角色
SELECT (auth.jwt()->>'user_role')::app_role
INTO user_role;
-- 如果 JWT 里没有角色,返回 false
IF user_role IS NULL THEN
RETURN false;
END IF;
-- 检查角色是否有该权限
RETURN EXISTS (
SELECT 1 FROM public.role_permissions
WHERE role = user_role
AND permission = requested_permission
);
END;
$$;
-- 授权给 authenticated 角色
GRANT EXECUTE ON FUNCTION public.authorize(app_permission)
TO authenticated;
RLS Policy 调用 authorize()
现在可以在 Policy 里用 authorize() 检查权限:
-- 只有 admin/moderator 能删除帖子
CREATE POLICY "Role-based delete" ON posts
FOR DELETE TO authenticated
USING (
authorize('posts.delete') OR auth.uid() = author_id
);
-- 只有 admin 能置顶帖子
CREATE POLICY "Admin pin posts" ON posts
FOR UPDATE TO authenticated
USING (
NOT is_pinned OR authorize('posts.pin')
);
第一个 Policy 的逻辑:用户能删除帖子,要么有 posts.delete 权限(admin/moderator),要么是帖子作者。
第二个 Policy 的逻辑:修改帖子时,如果要把 is_pinned 设为 true,必须有 posts.pin 权限。
前端读取 Custom Claims
Custom Claims 在 JWT 里,前端需要解码 access_token 才能读取:
import { jwtDecode } from 'jwt-decode'
// 获取 session
const { data: { session } } = await supabase.auth.getSession()
if (session) {
const decoded = jwtDecode(session.access_token)
console.log('用户角色:', decoded.user_role)
console.log('租户ID:', decoded.tenant_id)
}
注意 jwtDecode 只解码 JWT,不验证签名。前端不需要验证——验证在 Supabase 服务端完成。
五、企业 SaaS 完整场景实战
现在我们把前面学的东西组合起来,构建一个完整的企业 SaaS 认证方案。
场景是这样的:你的 SaaS 产品有两类用户:
- 企业用户:通过客户公司的 SSO 登录(Okta/Azure AD),账号归属某个租户
- 个人用户:通过 Google/GitHub OAuth 登录,不属于任何租户,只能访问公开资源
数据隔离要求:企业用户只能看到自己租户的数据,个人用户不能看到企业数据。
用户表设计
CREATE TABLE public.users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL,
-- 认证来源
auth_type TEXT NOT NULL DEFAULT 'oauth', -- 'oauth' 或 'sso'
-- SSO 用户专属字段
sso_provider_id TEXT, -- SSO 连接的 ID
tenant_id UUID, -- 所属租户
-- 基本信息
full_name TEXT,
avatar_url TEXT,
-- 时间戳
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- 租户表
CREATE TABLE public.tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
sso_provider_id TEXT NOT NULL UNIQUE, -- 与 SSO 连接关联
plan_type TEXT NOT NULL DEFAULT 'team', -- 'team' 或 'enterprise'
created_at TIMESTAMPTZ DEFAULT now()
);
Custom Access Token Hook 统一处理
Hook 需要区分两种用户:
CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event jsonb)
RETURNS jsonb
LANGUAGE plpgsql
AS $$
DECLARE
claims jsonb;
user_record RECORD;
BEGIN
claims := event->'claims';
-- 获取用户信息
SELECT auth_type, sso_provider_id, tenant_id, role
INTO user_record
FROM public.users
WHERE id = (event->>'user_id')::uuid;
-- 如果用户不存在(刚注册),跳过
IF user_record IS NULL THEN
RETURN jsonb_build_object('claims', claims);
END IF;
-- 添加认证类型
claims := jsonb_set(claims, '{auth_type}', to_jsonb(user_record.auth_type));
-- SSO 用户添加 tenant_id 和 sso_provider_id
IF user_record.auth_type = 'sso' THEN
IF user_record.tenant_id IS NOT NULL THEN
claims := jsonb_set(claims, '{tenant_id}', to_jsonb(user_record.tenant_id));
END IF;
IF user_record.sso_provider_id IS NOT NULL THEN
claims := jsonb_set(claims, '{sso_provider_id}', to_jsonb(user_record.sso_provider_id));
END IF;
END IF;
-- 添加用户角色(如果有)
IF user_record.role IS NOT NULL THEN
claims := jsonb_set(claims, '{user_role}', to_jsonb(user_record.role));
END IF;
RETURN jsonb_build_object('claims', claims);
END;
$$;
登录后的用户创建流程
OAuth 和 SSO 登录都会触发 auth.users 表的记录创建。我们需要在登录成功后,把用户信息同步到 public.users 表。
可以用 Auth Hooks 的 mfa_verification_hook 或者业务代码处理:
// 在 auth callback 页面,登录成功后
const { data: { user } } = await supabase.auth.getUser()
if (user) {
// 检查用户是否已存在于 public.users
const { data: existingUser } = await supabase
.from('users')
.select('id')
.eq('id', user.id)
.single()
if (!existingUser) {
// 判断登录方式
const authType = user.app_metadata?.provider || 'oauth'
// 创建用户记录
await supabase.from('users').insert({
id: user.id,
email: user.email,
auth_type: authType.startsWith('sso') ? 'sso' : 'oauth',
sso_provider_id: user.app_metadata?.sso_provider_id,
tenant_id: null, // 后面由管理员分配
full_name: user.user_metadata?.full_name,
avatar_url: user.user_metadata?.avatar_url
})
}
}
统一的 RLS Policy
业务表需要有统一的 RLS Policy,同时处理 OAuth 用户和 SSO 用户:
-- 假设有一个 projects 表
CREATE TABLE public.projects (
id UUID PRIMARY KEY,
tenant_id UUID REFERENCES tenants(id),
name TEXT NOT NULL,
is_public BOOLEAN DEFAULT false,
created_by UUID REFERENCES users(id)
);
ALTER TABLE public.projects ENABLE ROW LEVEL SECURITY;
-- 全局隔离 Policy(RESTRICTIVE)
CREATE POLICY "Tenant or public access" ON public.projects
AS RESTRICTIVE TO authenticated
USING (
-- SSO 用户:必须匹配 tenant_id
(auth.jwt()->>'auth_type' = 'sso'
AND tenant_id = (auth.jwt()->>'tenant_id')::uuid)
OR
-- OAuth 用户:只能看公开项目
(auth.jwt()->>'auth_type' = 'oauth' AND is_public = true)
);
-- 允许查看(PERMISSIVE)
CREATE POLICY "Users can view" ON public.projects
FOR SELECT TO authenticated
USING (true);
-- 允许创建(租户用户才能创建)
CREATE POLICY "Tenant users can create" ON public.projects
FOR INSERT TO authenticated
WITH CHECK (
auth.jwt()->>'auth_type' = 'sso'
AND tenant_id = (auth.jwt()->>'tenant_id')::uuid
);
-- 允许编辑(创建者或管理员)
CREATE POLICY "Creators or admins can edit" ON public.projects
FOR UPDATE TO authenticated
USING (
created_by = auth.uid()
OR authorize('projects.edit')
);
这套 Policy 的逻辑:
- SSO 用户只能看到自己租户的项目
- OAuth 用户只能看到公开项目
- 只有 SSO 用户能创建项目(需要归属租户)
- 编辑权限:创建者或有
projects.edit权限的管理员
企业客户的管理员分配租户
SSO 用户刚登录时 tenant_id 是 null。需要企业管理员手动分配:
-- 管理员把用户加入租户
UPDATE public.users
SET tenant_id = '<租户UUID>'
WHERE id = '<用户UUID>';
-- 设置用户角色
INSERT INTO public.user_roles (user_id, role)
VALUES ('<用户UUID>', 'admin');
这个过程可以做成一个管理界面,或者用 Auth Hooks 自动处理(根据 SSO provider 匿名映射到租户)。
完整流程图示
用户登录
│
├─ OAuth 用户
│ │
│ ├─ Google/GitHub 回调
│ ├─ Supabase 创建 auth.users
│ ├─ 业务代码创建 public.users (auth_type='oauth')
│ └─ JWT: { auth_type: 'oauth' }
│ │
│ └─ RLS: 只能看到 is_public=true 的数据
│
└─ SSO 用户
│
├─ Okta/Azure AD SAML 回调
├─ Supabase 创建 auth.users (带 sso_provider_id)
├─ 业务代码创建 public.users (auth_type='sso')
├─ 管理员分配 tenant_id
├─ Custom Hook 添加 tenant_id 到 JWT
└─ JWT: { auth_type: 'sso', tenant_id: '...' }
│
└─ RLS: 只能看到 tenant_id 匹配的数据
Supabase Auth 企业级配置完整流程
从 OAuth 社交登录到 SAML SSO 企业集成,再到 RLS 多租户权限隔离的完整配置步骤
⏱️ 预计耗时: 2 小时
- 1
步骤1: 配置 OAuth Provider(Google/GitHub/Apple)
1. 在 Provider 控制台创建 OAuth App(Google Cloud Console / GitHub Settings)
2. 设置回调地址:https://<项目ref>.supabase.co/auth/v1/callback
3. 在 Supabase Dashboard 启用 Provider,填入 Client ID 和 Secret
4. 代码调用 signInWithOAuth(),指定 scopes 和 redirectTo - 2
步骤2: 配置 SAML SSO 企业集成
1. 从企业 IdP(Okta/Azure AD)获取 Metadata 文件或 URL
2. 使用 CLI 添加 SSO 连接:supabase sso add --type saml --domains company.com
3. 配置 Attribute Mapping,映射 email、name 等字段
4. 测试登录流程:用户输入邮箱后自动跳转到 IdP - 3
步骤3: 实现 RLS 多租户隔离
1. 在业务表添加 tenant_id 字段
2. 启用 RLS:ALTER TABLE projects ENABLE ROW LEVEL SECURITY
3. 创建 RESTRICTIVE Policy 实现全局租户过滤
4. 创建 PERMISSIVE Policy 控制具体操作权限(SELECT/INSERT/UPDATE)
5. 给 tenant_id 字段添加索引优化性能 - 4
步骤4: 配置 Custom Access Token Hook
1. 创建 public.custom_access_token_hook() 函数
2. 在函数中添加 tenant_id、user_role 等 custom claims
3. 授权 supabase_auth_admin 执行函数
4. 在 Supabase Dashboard 启用 Hook(Authentication > Hooks)
5. 前端通过 jwtDecode() 读取 custom claims - 5
步骤5: 实现 RBAC 权限控制
1. 创建 user_roles 和 role_permissions 表
2. 定义 app_role 和 app_permission 枚举类型
3. 创建 authorize() 函数检查用户权限
4. 在 RLS Policy 中调用 authorize('permission.name')
5. 前端根据 JWT 中的 user_role 显示/隐藏 UI 元素
常见问题
OAuth 和 SAML SSO 有什么区别?我该选哪个?
如果你做 B2B SaaS,SSO 是必备功能;如果是个人产品,OAuth 就够了。
启用 RLS 后查询返回空结果,怎么办?
RLS Policy 性能差,如何优化?
• 给 Policy 中的字段建索引:CREATE INDEX idx_tenant ON projects(tenant_id);
• 避免 Policy 中的子查询:用 Custom Access Token Hook 把 tenant_id 放进 JWT,直接用 auth.jwt()->>'tenant_id' 提取
• 用 SECURITY DEFINER 函数封装复杂逻辑,PostgreSQL 会优化成只执行一次
如何在 JWT 中存储自定义字段(如 tenant_id、user_role)?
1. 创建 PL/pgSQL 函数 custom_access_token_hook(event jsonb)
2. 在函数中用 jsonb_set() 添加 custom claims
3. 在 Supabase Dashboard 启用 Hook(Authentication > Hooks > Custom Access Token)
4. 前端用 jwtDecode() 读取 JWT 中的 custom claims
多租户 SaaS 如何实现企业用户和个人用户的数据隔离?
• SSO 用户:只能看到 tenant_id 匹配的租户数据
• OAuth 用户:只能看到 is_public=true 的公开数据
使用 RESTRICTIVE Policy 作为全局过滤器,PERMISSIVE Policy 控制具体操作。
Supabase 支持 Apple Sign In 吗?配置复杂吗?
1. 在 Apple Developer Portal 创建 Services ID
2. 生成私钥(.p8 文件,只能下载一次)
3. 在 Supabase Dashboard 填入 Team ID、Key ID、Services ID 和私钥内容
iOS 应用可以用原生 Sign in with Apple API,拿到 identity_token 后传给 Supabase 的 signInWithIdTokenCredentials()。
企业客户要求 SSO,但我不知道他们的 IdP 类型,怎么办?
我们从 OAuth 社交登录起步,走到 SAML SSO 企业集成,最后用 RLS 和 Custom Claims 搭起完整的权限体系。这套方案能覆盖从个人产品到企业 SaaS 的各种场景。
几个关键决策点:
如果你的用户主要是个人消费者:OAuth(Google/GitHub)就够了。配置简单,用户习惯,维护成本低。RLS 基础版——用户只能访问自己的数据——足矣。
如果你做 B2B SaaS:SSO 是必备功能。企业客户会要求,没有的话可能直接被淘汰。准备好 Okta/Azure AD/Google Workspace 的集成,预留多租户架构。
如果你做复杂的企业应用:RBAC + RLS 组合是标准方案。角色权限表、Custom Claims、authorize() 函数——这套组合拳能处理精细的权限需求,比如”管理员能删除但不能置顶”、“租户成员能编辑但不能删除”。
下一步建议:先从 OAuth 开始,跑通基本流程。等项目成熟、有企业客户需求时,再叠加 SSO 和 RBAC。Supabase 的架构支持渐进式升级,不用一开始就把所有功能都配好。
13 分钟阅读 · 发布于: 2026年4月21日 · 修改于: 2026年4月25日
Supabase 实战指南
如果你是从搜索进入这篇文章,建议顺手补上上一篇或继续下一篇,这样更容易把同一主题读完整。
上一篇
Supabase Edge Functions 实战:Deno 运行时与 TypeScript 开发指南
深入学习 Supabase Edge Functions 开发实战:理解 Deno 运行时架构与 V8 isolate 原理,掌握 CLI 命令流程,用 Hono 框架构建 RESTful API,从本地调试到生产部署的完整指南
第 5 / 8 篇
下一篇
Supabase Storage 实战:文件上传、CDN 与访问控制
Supabase Storage 完整实战指南:三种访问控制模式对比、TUS 分片上传、Smart CDN 优化技巧、与 R2/S3 价格对比分析。包含 React 代码示例和问题排查方案。
第 7 / 8 篇
相关文章
Supabase 入门:PostgreSQL + Auth + Storage 一站式后端
Supabase 入门:PostgreSQL + Auth + Storage 一站式后端
Supabase 数据库设计:表结构、关系与 Row Level Security 完全指南
Supabase 数据库设计:表结构、关系与 Row Level Security 完全指南
Supabase Auth 实战:邮箱验证、OAuth 与会话管理

评论
使用 GitHub 账号登录后即可评论