你在 Supabase Table Editor 里能看到 posts 表有 100 条数据。但前端 await supabase.from('posts').select('*') 返回空数组 [],还不报错。或者更怪:登录的用户能看到自己的 posts,但看不到别人的——明明你写的是 public feed。
这是 Row Level Security(RLS)在静默地把你过滤掉了。在 Table Editor 里建的表默认就开启了 RLS,而默认规则是开启 RLS + 没有命中任何 policy = 拒绝所有行。Postgres 对被拒绝的 SELECT 不会报错,只是把行从结果里删掉,所以你拿到的是空数组,而不是 42501 权限错误。“silent empty result”(静默空结果)就是 RLS 的典型症状,也是 Supabase 官方排错文档对”表里有数据但 select 返回 []”给出的答案。
**TL;DR——最快修复:**在 SQL Editor 里跑 SELECT * FROM pg_policies WHERE tablename = 'posts';。如果返回 0 行,说明你开了 RLS 但没写 policy。加一条 SELECT policy(下方有模板),重跑前端 query,搞定。如果已经有 policy 了,直接看下面的分类对照表,找出是哪条规则把你的行拒掉了。
你属于哪一类
把你的症状对到表里的某一行,然后直接跳到对应修复。
| 症状 | 最可能的原因 | 修复 |
|---|---|---|
登录与否都返回 [] | 开了 RLS 但没有 SELECT policy | 加一条 SELECT policy(Step 3) |
| 登录用户能看到自己的行,别人看不到任何 | policy 卡在 auth.uid(),没有 public 分支 | 加上 OR is_public 分支(Step 3) |
SQL Editor 能查到,App 里返回 [] | App 用的是 publishable/anon key(受 RLS 约束);Editor 以 postgres 运行(绕过 RLS) | 给 anon/authenticated role 加 policy |
能 INSERT,但读刚写的行又是 [] | 有 INSERT policy,缺 SELECT policy | 补一条对应的 SELECT policy(Step 4) |
policy 看着没错但所有人都是 [] | USING 里 join/逻辑写错,或者只有 RESTRICTIVE policy 没有 PERMISSIVE 的 | 重查条件;确认 policy 是 PERMISSIVE(Step 6) |
之前能用,登录后突然 [] | JWT 没带上或过期 → auth.uid() 是 null | 重新检查 session(Step 5) |
常见原因
按命中率从高到低排。
1. 开了 RLS 但没写 SELECT policy
最高频。表是在 Table Editor 里建的(默认开 RLS),或者你点了 “Enable RLS” 却没加任何 policy,于是任何 query 都返回 0 行。Dashboard 会在表名旁显示 “RLS enabled, no policies” 警告,但很容易被忽略。
如何判断:Table Editor → 你的表 → 看是否有一个 “RLS enabled, 0 policies” / “Unprotected” 小标记,或者跑上面的 pg_policies query,什么都没返回。
2. policy 需要 auth.uid() 但请求是匿名的
CREATE POLICY "users see own posts" ON posts
FOR SELECT USING (auth.uid() = author_id);
匿名请求的 auth.uid() 是 null,所以这个比较永远是 false,什么都查不到。
如何判断:登录能查到,未登录查不到。
3. 客户端用的是 publishable/anon key(所以受 RLS 约束)
浏览器端客户端用的是你的 publishable key(sb_publishable_...)——老项目上则是 legacy anon key。两者都以 anon/authenticated role 运行,完全受 RLS 约束。一个常见的错误假设是以为 supabase-js 会自动绕过 RLS,其实并不会。只有 secret key(sb_secret_...,原来的 service_role)才绕过 RLS,而它绝不能进浏览器。
截至 2026 年 6 月,Supabase 已迁移到 publishable(
sb_publishable_...)和 secret(sb_secret_...)API key。旧的anon/service_roleJWT key 仍然能用,但已被弃用,计划在 2026 年底移除;2025 年 11 月之后建的项目可能根本没有这两个旧 key。两个新 key 在 Settings → API Keys → Publishable and secret API keys 下能找到。
如何判断:同一个 query 在 SQL Editor(以 postgres 运行,绕过 RLS)能查到数据,但 App 里返回 []。
4. 有 SELECT policy 但缺 INSERT/UPDATE policy
-- 只加了 SELECT policy
CREATE POLICY "see own" ON posts FOR SELECT USING (...);
INSERT 还是被拒。或者反过来:能 INSERT,但读回那一行返回空,因为没有对应的 SELECT policy。每个 operation 都要有自己的 policy。
如何判断:能写入但读不到刚写的(或者反过来)。
5. policy 里 join/逻辑写错
CREATE POLICY "team members see post" ON posts
FOR SELECT USING (
EXISTS (
SELECT 1 FROM team_members
WHERE team_id = posts.team_id -- 表关联这步对……
AND user_id = posts.author_id -- 但这里永远匹配不到当前调用者
)
);
成员判断比的是 posts.author_id 而不是 auth.uid(),所以实际上永远 false。结构对,谓词错。
如何判断:policy 读起来合理,但谁都看不到任何行。
6. JWT 没传 / 过期
前端根本没把 session 传给 Supabase client,或者 access token 过期了,于是 auth.uid() 解析成 null。
如何判断:await supabase.auth.getSession()——session 是否存在且未过期?
7. 只有 RESTRICTIVE policy,没有 PERMISSIVE policy 来放行
policy 默认是 PERMISSIVE(任意一条命中即放行)。如果你建了 AS RESTRICTIVE 的 policy,它只能收紧权限,本身永远不会放行任何行。如果只有 restrictive policy、没有 permissive 的 SELECT policy,结果还是 []。
如何判断:SELECT polname, polpermissive FROM pg_policy WHERE polrelid = 'posts'::regclass;——如果每行都是 polpermissive = f,说明没有任何 permissive policy 在放行读取。
最短修复路径
Step 1:确认数据真的存在(以 postgres 运行,绕过 RLS)
-- Supabase Dashboard → SQL Editor
-- SQL Editor 以 postgres role 运行,会绕过 RLS
SELECT count(*) FROM posts; -- 期望 100
SELECT * FROM posts LIMIT 5; -- 应该返回行
如果这里能查到数据,说明是 RLS 在拦,而不是表空了或不存在。
Step 2:列出表上的 policy
SELECT * FROM pg_policies WHERE tablename = 'posts';
或者打开 Dashboard → Authentication → Policies → posts(也可以从 Table Editor 的 RLS 标记点进去)。这里返回 0 行就确认了原因 #1。
Step 3:加一条 SELECT policy
最简单的”所有人可读”:
CREATE POLICY "anyone can read posts" ON posts
FOR SELECT
TO anon, authenticated
USING (true);
更安全的”登录用户读 published 的行”:
CREATE POLICY "authenticated read published" ON posts
FOR SELECT
TO authenticated
USING (status = 'published');
或者”读自己的 + 公开的”:
CREATE POLICY "own or public" ON posts
FOR SELECT
TO authenticated
USING (author_id = auth.uid() OR is_public = true);
部署方式:直接在 SQL Editor 里运行,或者写进 supabase/migrations/ 让改动可复现:
supabase migration new add_posts_select_policy
# 把 CREATE POLICY 语句粘进生成的 SQL 文件
supabase db push
Step 4:按 operation 分别加 policy
每个 operation 都需要单独的 policy。注意 INSERT 用 WITH CHECK,而 SELECT/UPDATE/DELETE 用 USING(UPDATE 两个都能用)。
-- 读:所有登录用户
CREATE POLICY "auth read" ON posts FOR SELECT
TO authenticated USING (true);
-- 写:只能以本人身份
CREATE POLICY "own insert" ON posts FOR INSERT
TO authenticated WITH CHECK (author_id = auth.uid());
CREATE POLICY "own update" ON posts FOR UPDATE
TO authenticated USING (author_id = auth.uid())
WITH CHECK (author_id = auth.uid());
CREATE POLICY "own delete" ON posts FOR DELETE
TO authenticated USING (author_id = auth.uid());
Step 5:确认客户端带上了 token
// 前端
const { data: { session } } = await supabase.auth.getSession();
console.log('session:', session); // 应该包含 access_token
// 有 session 时 supabase-js 会自动带上 token,但还是确认一下:
const { data, error } = await supabase.from('posts').select('*');
console.log(data, error);
如果 session 是 null,说明用户没登录,query 以 anon 身份发出——那么卡在 auth.uid() 的 policy 自然什么都查不到。
Step 6:在 SQL Editor 里复现 policy 判断
你可以模拟具体的 role + 用户,看看哪些行能通过 policy:
-- 在 SQL Editor 里,针对单条语句模拟一个登录用户
SET LOCAL ROLE authenticated;
SET LOCAL "request.jwt.claims" TO '{"sub": "your-user-uuid", "role": "authenticated"}';
SELECT * FROM posts;
RESET ROLE;
如果这里能查到行而 App 里查不到,说明 App 没把这个 JWT 带上(回到 Step 5)。如果连这里都返回 [],那就是你的 USING 条件把行拒掉了——回头看原因 #5 和 #7。
Step 7:服务端用 secret key 绕过 RLS
某些服务端场景(cron job、admin endpoint、受信任的后端服务)确实需要看到全部数据。用 secret key(sb_secret_...,老项目上是 legacy service_role key):
import { createClient } from '@supabase/supabase-js';
const supabaseAdmin = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SECRET_KEY!, // sb_secret_... —— 仅服务端用,绕过 RLS
{ auth: { autoRefreshToken: false, persistSession: false } }
);
const { data } = await supabaseAdmin.from('posts').select('*'); // 绕过 RLS
secret / service_role key 绝对不能进浏览器。把它放在仅服务端的环境变量里;千万别用 NEXT_PUBLIC_、VITE_ 或 PUBLIC_ 前缀。
如何确认已修好
- 重跑那个失败的前端调用,把两个字段都打出来:
const { data, error } = await supabase.from('posts').select('*'); console.log(data?.length, error);——你要的是非零长度 +null的 error。 - 确认 policy 已生效且是 permissive 的:
SELECT polname, polpermissive FROM pg_policy WHERE polrelid = 'posts'::regclass;应该列出你的新 policy,且polpermissive = t。 - 也测一下反面:登出(或换匿名 client)确认你只看得到本该公开的内容。如果看到了不该暴露的行,说明你的
USING (true)范围太宽了。
预防建议
- 建表时一起写 RLS policy,policy 走 git migration,不要在 Dashboard 手改。
- 生产环境永远不要关 RLS。对 publishable/anon key 全开的表就是一场等着发生的数据泄露。
- 给每个表的四个 operation(
SELECT/INSERT/UPDATE/DELETE)都写明确的 policy,再按需放宽。 - policy 一定用
TO authenticated或TO anon指定 role;不指定就会作用于所有 role,包括anon。 - 复杂一点的 policy,上线前用
SET LOCAL ROLE自测一遍。 - secret(
sb_secret_.../service_role)key 只放服务端 env;浏览器永远用 publishable/anon key。 - 定期以
anon身份审计:“未登录用户能读到哪些表?“,及时发现意外暴露。 - 把每个表的”谁能读 / 谁能写”写进文档,新人加表时照着写 policy。
常见问题
为什么 RLS 返回空数组而不是权限错误?
Postgres 把被拒绝的 SELECT 当成行过滤,而不是访问违规,所以那些行只是从结果里被移除。只有 INSERT/UPDATE/DELETE 违规才会报错(42501 / new row violates row-level security policy)。一个空的 SELECT 加上 error === null,就是 RLS 的标志性表现。
新的 publishable key 会改变 RLS 行为吗?
不会。publishable key(sb_publishable_...)和旧的 anon key 一样是低权限,所以你的 RLS policy 行为完全一致。只有 secret key(sb_secret_...,原 service_role)才绕过 RLS。
我直接把 RLS 关掉让它能用行不行?
不行。关掉 RLS 会让整张表对任何拿到 publishable/anon key 的人开放(而这个 key 会打包进你的客户端代码)。正确做法是加一条有范围的 SELECT policy——这才是修复,而不是关 RLS 这种绕过。
SQL Editor 里能用,App 里不行——为什么?
SQL Editor 以 postgres role 运行、绕过 RLS,而你的 App 以 anon 或 authenticated 运行、受 RLS 约束。一个在 Editor 里能查、在 App 里返回 [] 的 query,几乎总是因为缺少针对那个 role 的、能命中的 policy。
怎么以某个具体登录用户的身份测试 policy?
在 SQL Editor 里,先跑 SET LOCAL ROLE authenticated;,再 SET LOCAL "request.jwt.claims" TO '{"sub":"<uuid>","role":"authenticated"}';,然后执行你的 SELECT,最后 RESET ROLE;。这能精确复现那个用户通过 API 看到的结果。