返回博客列表
技术文章Supabase微信小程序数据安全RLS

Row Level Security 深度指南

2026年04月🇨🇳 中文

什么是 Row Level Security (RLS)?

Row Level Security (行级安全,简称 RLS) 是一种极具威力的数据库权限控制特性。顾名思义,它允许你定义安全策略(Policies),将数据的访问和修改权限精细地控制到每一行

在没有开启 RLS 的情况下,如果某个用户对某张表有 SELECT 权限,他就能查阅表里的所有数据。而在开启 RLS 后,你可以向数据库制定规则,比如:“普通用户只能查阅 user_id 是自己 ID 的行”或者“文章状态为 published 的行才允许所有人查阅”。当 SQL 语句执行时,数据库引擎会在最底层自动、静默地追加这些过滤条件,把无权访问的行拦截在外。

RLS 的核心使用场景:

  1. Serverless / BaaS 架构(如 Supabase):在这种架构下,前端直接通过 API 发起数据库的读写,彻底绕过了传统的后端应用。此时,RLS 就成为了保护用户数据不被越权访问的唯一且最核心的防线。
  2. 多租户 SaaS 架构:无数家企业的数据存放在同一张表中。利用 RLS 可以在数据库底层强制隔离租户数据,确保 A 公司绝对无法因为代码 Bug 而读取到 B 公司的机密。
  3. 严苛的用户数据隔离:例如电商系统的订单表、医疗系统病历,利用数据库层的硬限制来杜绝越权访问。

所有数据库都支持 RLS 吗? 并不是。

  • 原生支持且完善:PostgreSQL(自 9.5 版本引入)、Oracle、SQL Server。Supabase 之所以能大放异彩,正是因为它深度挖掘并利用了 Postgres 强大的 RLS 特性。
  • 没有原生 RLS:MySQL、SQLite、MongoDB 等。在这些数据库中,如果要实现行级别的权限隔离,开发者必须在后端业务代码里手动拼接 WHERE user_id = ?,一旦某行代码漏写了这个条件,就会酿成严重的数据越权漏洞。

为什么在数据库层而不是应用层做鉴权

传统的鉴权逻辑放在应用服务器:接收请求 → 验证 token → 查询数据库 → 过滤结果 → 返回。这个模式有一个根本性的脆弱点:鉴权和数据访问是两个独立的步骤,中间有一个隐式的信任跳跃——数据库信任应用服务器会正确过滤数据。

如果应用层代码有 bug(比如缺少了一个 WHERE user_id = current_user_id 条件),数据会被错误地暴露;如果系统有多个服务直连数据库,每个服务都需要独立实现过滤逻辑,遗漏风险乘以 N。

Row Level Security 把鉴权逻辑下沉到数据库本身,让数据在离开数据库之前就被过滤。无论哪个应用服务器、哪种访问路径,只要是通过特定角色连接数据库,都受到同样的策略约束。这是一种更底层、更强制性的安全边界。

在 Supabase 的架构中,这个特性尤其重要。PostgREST 直接将数据库表暴露为 REST API,没有应用服务器这一层。RLS 是防止用户直接访问他人数据的唯一防线。


PostgreSQL 的角色体系与 Supabase 的映射

RLS 策略是基于数据库角色生效的,不同角色看到的行不同。Supabase 预置了三个关键角色:

anon:代表未登录用户。当请求只携带 anon key(不包含 JWT)时,PostgREST 以 anon 角色执行查询。这个角色应该只能访问真正公开的数据。

authenticated:代表已登录用户。当请求携带有效的 access_token JWT 时,PostgREST 以 authenticated 角色执行查询,并且在查询会话中设置 JWT claims,使 auth.uid() 等函数能返回当前用户的信息。

service_role:服务端管理角色,完全绕过 RLS。这个角色只能在服务端代码中使用。一旦 service_role key 泄露到客户端,所有 RLS 策略形同虚设。

JWT 在 PostgREST 中的处理机制:PostgREST 验证 JWT 签名后,将 JWT payload 注入 PostgreSQL 的当前会话(通过 SET LOCAL "request.jwt.claims"),这使得 auth.uid()auth.jwt() 等函数能在 SQL 执行上下文中读取 token 里的信息。


如何创建和启用 RLS

在 Supabase(基于 PostgreSQL)中,让 RLS 生效只需要两个核心步骤:开启表的 RLS 开关编写具体的安全策略(Policy)

虽然 Supabase 提供了一个非常易用的可视化 Dashboard 让你点按创建,但理解其背后的 SQL 语法对于排查复杂问题至关重要。

步骤一:开启表的 RLS

默认情况下,新创建的表是关闭 RLS 的(即“门户大开”,允许任意读写)。开启它非常简单:

ALTER TABLE your_table_name ENABLE ROW LEVEL SECURITY;

⚠️ 关键陷阱:开启 RLS 后,如果不定义任何策略,表的默认行为会变成 “拒绝所有请求”(Default Deny)。这意味着前端通过 API 查询这张表时,将获取不到任何数据,也不会报错,只会返回一个空数组 []

步骤二:创建策略 (Policy)

使用 CREATE POLICY 语句为表添加具体的访问规则。一个完整的策略定义包含以下四大要素:

  1. 策略名称:一段人类可读的描述字符串。
  2. 作用操作 (FOR):指定该策略对哪种数据库操作生效(SELECT, INSERT, UPDATE, DELETE, 或涵盖所有的 ALL)。
  3. 作用角色 (TO):指定针对哪种身份生效(如前文提到的 authenticatedanon)。
  4. 验证条件 (USINGWITH CHECK):编写一段返回布尔值 (true/false) 的 SQL 表达式,决定是否放行数据。

实战示例:允许用户查看自己创建的文章

假设我们有一张 posts 表,里面有一个 author_id 字段记录创建者的 UUID。我们要实现“用户只能查阅自己写的文章”:

CREATE POLICY "允许用户查阅自己的文章" 
ON public.posts 
FOR SELECT 
TO authenticated 
USING ( author_id = auth.uid() );

这里的核心魔法在于 auth.uid()。这是 Supabase 在底层提供的一个辅助函数,它能实时从当前 API 请求头携带的 JWT 中解包出发出请求的用户的 UUID。引擎会将它与每一行的 author_id 进行全自动比对,不相等的行将被直接舍弃。


USING 和 WITH CHECK 的语义差异

这是 RLS 策略设计中最容易犯错的地方。

USING 子句 是一个行过滤条件,用于 SELECT / UPDATE / DELETE 操作。它决定了"哪些已存在的行对当前角色可见"。当你执行 SELECT * FROM postsUSING 条件被附加为隐式的 WHERE 子句;当你 DELETE FROM posts WHERE id = 'x',如果 id = 'x' 的行不满足 USING 条件,这行对当前角色不可见,DELETE 会成功但影响 0 行(不会报错)。

WITH CHECK 子句 是一个写入验证条件,用于 INSERT / UPDATE 操作。它决定了"当前角色可以写入什么样的数据"。如果写入的数据不满足 WITH CHECK,操作会返回错误(而不是静默失败)。

UPDATE 操作需要同时声明两者。 这是最容易遗漏的规则。USING 控制"可以修改哪些行"(读取阶段),WITH CHECK 控制"修改后的数据必须满足什么条件"(写入阶段)。如果只有 USING 而没有 WITH CHECK,用户可以把行的 author_id 字段改成别人的 UUID——改完后数据仍然存在,只是"所有权"被转移了,而 USING 只检查改之前的数据。

-- 危险:只有 USING,可以把 author_id 改成别人的 ID
CREATE POLICY "bad"
ON posts FOR UPDATE
USING (author_id = auth.uid());

-- 正确:同时限制"改哪行"和"改成什么"
CREATE POLICY "safe"
ON posts FOR UPDATE
USING (author_id = auth.uid())
WITH CHECK (author_id = auth.uid());

策略模板与设计模式

公开只读表(如新闻列表、商品目录):SELECT 对所有人开放(USING (true)),写操作只允许特定角色。

私有数据(如订单、私信):SELECT 只允许本人(USING (user_id = auth.uid())),INSERT 时用 WITH CHECK 确保写入者只能以自己身份发布数据。

组织内可见(如团队文档):USING 通过子查询检查当前用户是否在该组织的成员表中。这个模式需要特别注意性能,见后文。

软删除表:在 SELECTUSING 子句中加入 deleted_at IS NULL,使所有查询自动过滤已删除的行。注意这个策略只过滤查询结果,不阻止直接通过 id 访问已删除行(除非在 USING 中也加了这个条件)。

基于角色的权限(配合自定义 JWT claim):在 USING 中使用 auth.jwt() ->> 'user_role' = 'admin',让某些操作只对特定角色开放。


RLS 的性能影响与优化

RLS 策略中的子查询是对每一行执行的,这和普通查询优化器的工作方式完全不同。一个看起来简单的策略可能会引发严重的性能问题。

IN 子查询 vs EXISTS 子查询。 IN (SELECT ...) 在旧版 PostgreSQL 中会把子查询全量执行,然后在内存中做匹配。EXISTS (SELECT 1 ...) 允许查询计划器使用索引和短路求值——一旦找到一条匹配记录,立即返回 true,不继续扫描。在现代 PostgreSQL(12+)中,查询优化器通常能自动将 IN 转换为 EXISTS,但明确写 EXISTS 更安全且意图清晰。

索引是 RLS 性能的关键。 RLS 策略涉及的所有列都必须有索引。如果 USING (author_id = auth.uid())author_id 列没有索引,每次查询都是全表扫描。以下索引是必须的:

  • 所有策略中的 user_id / author_id
  • 组织内可见策略中,成员关系表的 (organization_id, user_id) 联合索引
  • 软删除策略中的 deleted_at 列(如果要支持只查已删除数据)

SET 而不是子查询存储稳定值。 如果策略中需要多次用到同一个计算值,可以用 PostgreSQL 的 SET LOCAL 临时变量或函数内联来减少重复计算。Supabase 的 auth.uid() 函数本身是稳定的(STABLE 函数,同一事务中只执行一次),不需要额外优化。

SECURITY DEFINER 函数绕过 RLS 的用途。 有时策略本身需要访问其他表中的数据(如检查用户的组织成员关系),但那张表也有 RLS。可以把子查询封装在 SECURITY DEFINER 函数中,使函数以定义者的权限执行,绕过被查询表的 RLS。这是一个强大但需要谨慎使用的技巧。


调试 RLS 的系统方法

模拟特定角色执行查询。 在 Supabase SQL Editor 中,可以用以下方式测试策略效果:

-- 以匿名用户身份执行
SET LOCAL ROLE anon;
SELECT * FROM posts;

-- 以特定已登录用户身份执行(注入 JWT claims)
SET LOCAL ROLE authenticated;
SET LOCAL "request.jwt.claims" = '{"sub": "specific-user-uuid", "role": "authenticated"}';
SELECT * FROM posts;

-- 测试完毕,恢复默认
RESET ROLE;

注意每次 SET LOCAL 的作用域是当前事务,在 SQL Editor 中执行完后会自动重置。

检查策略是否遗漏了某个操作。 启用 RLS 后,任何没有匹配策略的操作都会被默认拒绝(返回空结果,不报错)。这是最难排查的问题——业务报告"数据消失了",实际上是策略覆盖不全。用 pg_policies 系统表检查每张表的策略覆盖:

SELECT tablename, cmd, roles, qual AS using_expr, with_check AS check_expr
FROM pg_policies
WHERE schemaname = 'public'
ORDER BY tablename, cmd;

找出高风险配置。 以下两类情况需要立即排查:

  1. 业务表没有启用 RLS(查 pg_tablesrowsecurity = false 的表)
  2. 启用了 RLS 但没有任何策略(所有操作被默认拒绝,容易造成"数据消失"的假象)

常见陷阱清单

陷阱 1:默认拒绝行为理解错误。 启用 RLS 后没有任何策略时,所有行对所有角色都不可见。返回值是空数组,不是错误,非常迷惑。建议:启用 RLS 后立刻添加至少一条策略,哪怕是临时的 USING (true)

陷阱 2:service_role key 出现在客户端。 一旦泄露,所有 RLS 策略形同虚设。微信小程序代码会被编译打包,但并不等于安全——可以通过反编译工具提取。绝对不能在客户端代码中出现 service_role key。

陷阱 3:UPDATE 策略只写了 USING,没有 WITH CHECK 导致用户可以修改行的所有权字段,将数据"转让"给其他用户。

陷阱 4:策略中的子查询没有索引。 在小数据量时不明显,随着数据增长查询性能急剧下降,且难以定位根因(慢查询日志显示的是 SELECT 语句,不会直接指向 RLS 策略)。

陷阱 5:忘记为 anon 角色添加公开数据的读取策略。 新建表时经常只考虑已登录用户的策略,导致未登录用户看不到任何数据(即使这些数据本应公开)。