使用 Supabase 时,「RLS 策略里写 auth.uid()」几乎是标配。但 RLS 到底是 PostgreSQL 的功能还是 Supabase 魔改?JWT 从前端发出后究竟经过哪些组件?request.jwt.claim.sub 和 SET ROLE 又是在哪一层生效的?
本文聚焦 Supabase 如何把 JWT 与 PostgreSQL RLS 衔接起来。
阅读前提
若想流畅阅读本文,建议已具备:
- 已阅读 JWT 结构说明(本文默认你理解
sub、exp、验签与Authorization: Bearer) - 了解 SQL 基础:
SELECT/INSERT/UPDATE、WHERE、表与列 - 了解 PostgreSQL 角色与权限 的基本概念(
GRANT、ROLE) - 使用过 Supabase 或同类 BaaS(
supabase-js、anonkey、登录流程) - (可选)了解 HTTP REST API 与 JSON 响应
RLS 是 PG 原生能力
RLS(Row Level Security)是 PostgreSQL 自 9.5 版本起内置的标准能力,Supabase 没有修改 RLS 引擎本身。你在 Supabase 里写的策略语法、执行时机,与 PostgreSQL 官方文档一致:
ALTER TABLE ... ENABLE ROW LEVEL SECURITYCREATE POLICY ... ON table_name- 按角色(
TO authenticated)和命令(SELECT/INSERT/UPDATE/DELETE)控制行级访问 - 策略表达式里的
USING(读/更新/删除时过滤)和WITH CHECK(插入/更新时校验)
在裸 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 也能用」
主键即用户 ID — profiles 表常见写法:id uuid PRIMARY KEY REFERENCES auth.users(id),策略写 id = auth.uid(),不需要单独的 user_id。
策略过宽 — USING (true) 表示所有登录用户都能读全部行,不是按用户隔离。
列名不同 — 可能是 owner_id、author_id、created_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: true;UPDATE: 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 特有的坑。
小结
- RLS 是 PostgreSQL 原生能力;Supabase 提供
auth.uid()等胶水函数和 Auth + PostgREST 集成。 - 登录时:Auth 服务签发 JWT,
sub=auth.users.id(JWT 结构见独立文章)。 - 查数据时:PostgREST 验签 JWT,在同一事务里
SET ROLE+ 写入request.jwt.claim.sub。 - RLS 执行时:调用
auth.uid()读 session 变量,在数据库里过滤行。 - 行归属:不会自动绑定用户;需在表里存
user_id等列(或主键即用户 ID),策略里写列 = auth.uid()。
一句话:JWT 证明你是谁 → 表结构标明行归谁 → RLS 在服务端比较两者 → 客户端拿不到能绕过 RLS 的凭据。