Supabase RLS 原理与协作

最后更新:

使用 Supabase 时,「RLS 策略里写 auth.uid()」几乎是标配。但 RLS 到底是 PostgreSQL 的功能还是 Supabase 魔改?JWT 从前端发出后究竟经过哪些组件?request.jwt.claim.subSET ROLE 又是在哪一层生效的?

本文聚焦 Supabase 如何把 JWT 与 PostgreSQL RLS 衔接起来

阅读前提

若想流畅阅读本文,建议已具备:


RLS 是 PG 原生能力

RLS(Row Level Security)是 PostgreSQL 自 9.5 版本起内置的标准能力,Supabase 没有修改 RLS 引擎本身。你在 Supabase 里写的策略语法、执行时机,与 PostgreSQL 官方文档一致:

在裸 PostgreSQL 里也可以启用 RLS,只是需要自己实现「当前用户是谁」的函数。Supabase 额外提供的是 集成层

层级 来源 作用
RLS 引擎 PostgreSQL 真正做行级过滤
auth.uid()auth.jwt() Supabase auth schema 从当前会话的 JWT claim 里取用户 ID
anon / authenticated / service_role Supabase 预设角色 区分匿名、已登录、服务端特权
PostgREST(Data API) Supabase 校验 JWT,设置 DB 会话角色和 claim
Supabase Auth(GoTrue) Supabase 登录时签发 JWT,sub 对应 auth.users.id

为何安全

很多人误以为:前端用 supabase.from('posts').select() 时,是客户端在「过滤自己的数据」。

其实不是。 客户端只发 HTTP 请求;PostgreSQL 在服务端执行每条 SQL 时,自动加上 RLS 条件。用户改 JS、改请求体、甚至自己写 curl,都绕不过数据库里的策略。

可以把它想成:

客户端 = 只能递纸条的人
PostgREST = 门卫(验 JWT、选角色)
PostgreSQL + RLS = 真正的安检(决定你能看到/改哪些行)

协作流程

flowchart TD
    A["浏览器 / App"] -->|"anon key + JWT"| B["PostgREST / Supabase API"]
    B -->|"1. 校验 JWT 签名"| C{JWT 合法?}
    C -->|否| D[以 anon 角色连接]
    C -->|是| E[以 authenticated 角色连接]
    E -->|2. 注入 JWT claims| F[PostgreSQL 会话]
    D --> F
    F -->|3. 执行 SQL| G[RLS 引擎]
    G -->|4. 自动附加策略条件| H[只返回/修改允许的行]

客户端(不可信) — 手里只有 anon key 和用户 JWT,不能直接连库,也不能改 auth.uid()

PostgREST(门卫) — 用 JWT Secret 验签;无 token 用 anon,有效则用 authenticated;把 sub 等 claims 写入当前会话。

PostgreSQL 角色anon / authenticated 受 RLS 约束;service_role 通常绕过 RLS,仅限服务端。

RLS 策略 — 对每一行、每一次操作检查策略。例如:

ALTER TABLE posts ENABLE ROW LEVEL SECURITY;

CREATE POLICY "read_own"
ON posts FOR SELECT
TO authenticated
USING (user_id = auth.uid());

CREATE POLICY "insert_own"
ON posts FOR INSERT
TO authenticated
WITH CHECK (user_id = auth.uid());

用户 A 发 GET /rest/v1/posts 时,数据库实际等价于(简化理解):

SELECT * FROM posts
WHERE user_id = 'A的UUID';  -- RLS 自动加上

即使用户改成 GET /rest/v1/posts?user_id=eq.别人的UUID,RLS 仍会叠加 user_id = auth.uid()还是只能看到自己的行


两条请求路径

理解 auth.uid() 的关键,是区分 登录查数据 两条路径。JWT 不是每次请求都先走 Auth 服务再进 auth schema。

登录

sequenceDiagram
    participant 前端
    participant Auth服务 as Auth 服务 (GoTrue)
    participant DB as PostgreSQL

    前端->>Auth服务: POST /auth/v1/token (邮箱密码等)
    Auth服务->>DB: 查 auth.users,校验密码
    Auth服务->>Auth服务: 用 JWT Secret 签发 JWT
    Note over Auth服务: payload 含 sub = auth.users.id
    Auth服务-->>前端: 返回 access_token (JWT)

Auth 服务只做:认证 + 签发 JWT。JWT 里会有 sub(用户 UUID)、role(通常是 authenticated)、过期时间等。

查改数据

sequenceDiagram
    participant 前端
    participant PostgREST as PostgREST (/rest/v1)
    participant DB as PostgreSQL

    前端->>PostgREST: GET /rest/v1/posts<br/>apikey: anon_key<br/>Authorization: Bearer JWT
    PostgREST->>PostgREST: 用 JWT Secret 验签 JWT
    PostgREST->>DB: SET ROLE authenticated
    PostgREST->>DB: SET request.jwt.claim.sub = '用户UUID'
    PostgREST->>DB: SELECT * FROM posts
    DB->>DB: RLS 执行策略,调用 auth.uid()
    Note over DB: auth.uid() 读 request.jwt.claim.sub
    DB-->>PostgREST: 只返回通过策略的行
    PostgREST-->>前端: JSON 响应

要点:这次请求不经过 Auth 服务;JWT 直达 PostgREST;验签后 claims 写入数据库会话;RLS 调用 auth.uid() 读出 sub


auth.uid() 与 RLS

策略里写的 auth,是 PostgreSQL 数据库里的 auth schema,其中有一个函数 auth.uid()

不解析 JWT、不验签,只读 PostgREST 事先设好的会话变量:

-- 概念上等价于(简化版)
CREATE FUNCTION auth.uid() RETURNS uuid AS $$
  SELECT current_setting('request.jwt.claim.sub', true)::uuid;
$$ LANGUAGE sql STABLE;

sub 就是登录时 Auth 服务写进 JWT 的用户 ID(auth.users.id)。同 schema 里还有 auth.jwt()(完整 claims)和 auth.role()(当前角色)。

当 RLS 评估 USING (user_id = auth.uid()) 时,PostgreSQL 只是普通地调用这个函数

1. PostgREST 执行: SELECT * FROM posts WHERE ...
2. PostgreSQL 规划查询
3. RLS 引擎附加: AND user_id = auth.uid()
4. 求值 auth.uid() → 读 session 的 sub → 得到具体 UUID
5. 最终等价于: SELECT * FROM posts WHERE ... AND user_id = '550e8400-...'

JWT 不流经 auth schema;PostgREST 验签后,把 JWT 里的信息变成 session 变量,auth.uid() 再被 RLS 调用。

supabase-js 对应

// 登录 → 走 Auth 服务
await supabase.auth.signInWithPassword({ email, password })
// 返回 session.access_token (JWT)

// 查数据 → 走 PostgREST,自动带上 JWT
await supabase.from('posts').select('*')
// 内部: GET https://xxx.supabase.co/rest/v1/posts
// Headers: apikey=anon_key, Authorization=Bearer <JWT>

会话变量

PostgREST 连上 PostgreSQL 后,在处理这一条请求时,大致会做:

BEGIN;                                    -- 开始事务
SET LOCAL ROLE authenticated;             -- 切换「当前角色」
SET LOCAL request.jwt.claim.sub = '550e8400-e29b-...';  -- 写入 JWT claim
-- 然后执行真正的查询,例如 SELECT * FROM posts
COMMIT;
操作 PostgreSQL 机制 作用
SET ROLE / SET LOCAL ROLE 标准角色切换 决定用 anon 还是 authenticated 的权限和 RLS
SET request.jwt.claim.sub 自定义会话参数(GUC) 把 JWT 里的 sub 放进当前会话,供 auth.uid() 读取

两者都发生在 PostgreSQL 服务端这一次连接/事务里SET ROLE 决定匹配哪套策略;request.jwt.claim.sub 决定当前用户是哪个 UUID。PostgREST 用 SET LOCAL(事务级),避免连接池里请求之间泄露 sub


行与用户关联

一个常见困惑:开了 RLS,是不是每一行就自动和某个 uid 绑定了?我没加 uid 列,系统怎么知道是谁的数据?

答案:不会自动绑定。 RLS 只在你写的策略条件下,决定「这次请求能不能访问这一行」。auth.uid() 只回答「你是谁」,不回答「这行是谁的」——后者要靠表结构 + 策略你自己设计。

概念 来源 作用
auth.uid() 当前 JWT 的 sub 这次请求的用户 UUID
表里的列(如 user_id 你自己建的 schema 标记这行数据属于谁

策略把两者显式关联USING (user_id = auth.uid())。没有这类列(或等价关联),RLS 不会凭空知道归属关系。

为何像「没加 uid 也能用」

主键即用户 IDprofiles 表常见写法:id uuid PRIMARY KEY REFERENCES auth.users(id),策略写 id = auth.uid(),不需要单独的 user_id

策略过宽USING (true) 表示所有登录用户都能读全部行,不是按用户隔离。

列名不同 — 可能是 owner_idauthor_idcreated_by,不一定叫 uid

只开 RLS 没写策略 — 默认谁都访问不了,不是自动按用户分行。

正确做法

CREATE TABLE posts (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id uuid NOT NULL REFERENCES auth.users(id),  -- 需自行添加
  title text,
  content text
);

ALTER TABLE posts ENABLE ROW LEVEL SECURITY;

CREATE POLICY "select_own"
ON posts FOR SELECT TO authenticated
USING (user_id = auth.uid());

CREATE POLICY "insert_own"
ON posts FOR INSERT TO authenticated
WITH CHECK (user_id = auth.uid());

插入时须在 WITH CHECK 里强制 user_id = auth.uid(),防止客户端冒充他人。也可用 trigger / default 自动写入 auth.uid()

关联示意

auth.users.id          posts.user_id(自建列)
     │                        │
     │    JWT sub             │  存在每一行
     └──────→ auth.uid() ←────┘  策略比较两者

              user_id = auth.uid()

常见模式

模式 需要 user_id? 策略示例
用户资料 否(id = 用户 UUID) id = auth.uid()
帖子 / 订单 user_id = auth.uid()
公开读、作者改 SELECT: trueUPDATE: user_id = auth.uid()
团队共享 team_id team_id IN (...)

开启 RLS ≠ 自动按用户分行。 必须在表里标明归属,再在 policy 里写 那一列 = auth.uid()


协作总图

┌──────────── 登录阶段(偶尔) ────────────┐
│  前端 ──→ Auth 服务 ──→ 签发 JWT        │
│              ↓                          │
│         auth.users 表存用户              │
└─────────────────────────────────────────┘

┌──────────── 数据请求(每次) ────────────┐
│  前端 ──→ PostgREST (/rest/v1)          │
│              │                          │
│              ├─ 1. 验签 JWT (JWT Secret) │
│              ├─ 2. SET ROLE authenticated│
│              ├─ 3. SET claim.sub = UUID  │
│              └─ 4. 执行 SQL              │
│                    ↓                    │
│              PostgreSQL                 │
│                    │                    │
│              RLS 引擎评估 policy         │
│                    │                    │
│              调用 auth.uid()            │
│                    │                    │
│              读 request.jwt.claim.sub   │
└─────────────────────────────────────────┘

Auth 服务 和 auth.uid() 在数据请求时不直接通信

常见误解

误解 实际
开 RLS 就自动按用户分行 需自建归属列 + 写 policy
每次请求 JWT 都过 Auth 服务 只有登录/refresh 才过;查数据走 PostgREST
auth.uid() 会验证 JWT 不会,只读已注入的 session 变量
JWT 先进 auth schema 再进 RLS JWT 在 PostgREST 验签;DB 里只剩 claim 变量
auth schema = Auth 服务 auth schema 是数据库里的函数;Auth 服务是独立 GoTrue 进程
前端传的 user_id 决定身份 身份由 JWT 的 sub 决定,策略应信 auth.uid()
anon key 公开了数据库就裸奔 anon key 只标识项目;真正决定能读什么的是 RLS policy

安全风险

RLS 本身可靠,策略写错或密钥泄露才会出问题:

风险 后果
USING (true)anon 未登录也能读全部数据
service_role 放进前端 完全绕过 RLS
user_metadata 做权限判断 用户可改 metadata,提权
security definer 函数写错 函数内可能以高权限执行,绕过 RLS
直连数据库用 superuser 绕过一切 RLS
View 未设 security_invoker 可能用定义者权限,绕过底层表 RLS

另外,PostgreSQL RLS 里 UPDATE 需要先能 SELECT 那一行。只写 UPDATE policy、不写 SELECT policy → 更新会静默失败(0 行),这是 PG 行为,不是 Supabase 特有的坑。


小结

  1. RLS 是 PostgreSQL 原生能力;Supabase 提供 auth.uid() 等胶水函数和 Auth + PostgREST 集成。
  2. 登录时:Auth 服务签发 JWT,sub = auth.users.id(JWT 结构见独立文章)。
  3. 查数据时:PostgREST 验签 JWT,在同一事务里 SET ROLE + 写入 request.jwt.claim.sub
  4. RLS 执行时:调用 auth.uid() 读 session 变量,在数据库里过滤行。
  5. 行归属:不会自动绑定用户;需在表里存 user_id 等列(或主键即用户 ID),策略里写 列 = auth.uid()

一句话:JWT 证明你是谁 → 表结构标明行归谁 → RLS 在服务端比较两者 → 客户端拿不到能绕过 RLS 的凭据。