Supabase RLS 返回空数组(还不报错)

Supabase 表里明明有数据,前端 select 却拿到 [] 且不报错。对照表判断你属于哪一类 RLS 问题,加对 SELECT policy,几分钟搞定。

你在 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 keysb_publishable_...)——老项目上则是 legacy anon key。两者都以 anon/authenticated role 运行,完全受 RLS 约束。一个常见的错误假设是以为 supabase-js 会自动绕过 RLS,其实并不会。只有 secret keysb_secret_...,原来的 service_role)才绕过 RLS,而它绝不能进浏览器。

截至 2026 年 6 月,Supabase 已迁移到 publishablesb_publishable_...)和 secretsb_secret_...)API key。旧的 anon / service_role JWT 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 → Policiesposts(也可以从 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。注意 INSERTWITH CHECK,而 SELECT/UPDATE/DELETEUSINGUPDATE 两个都能用)。

-- 读:所有登录用户
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);

如果 sessionnull,说明用户没登录,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 keysb_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_ 前缀。

如何确认已修好

  1. 重跑那个失败的前端调用,把两个字段都打出来:const { data, error } = await supabase.from('posts').select('*'); console.log(data?.length, error);——你要的是非零长度 + null 的 error。
  2. 确认 policy 已生效且是 permissive 的:SELECT polname, polpermissive FROM pg_policy WHERE polrelid = 'posts'::regclass; 应该列出你的新 policy,且 polpermissive = t
  3. 也测一下反面:登出(或换匿名 client)确认你只看得到本该公开的内容。如果看到了不该暴露的行,说明你的 USING (true) 范围太宽了。

预防建议

  • 建表时一起写 RLS policy,policy 走 git migration,不要在 Dashboard 手改。
  • 生产环境永远不要关 RLS。对 publishable/anon key 全开的表就是一场等着发生的数据泄露。
  • 给每个表的四个 operation(SELECT / INSERT / UPDATE / DELETE)都写明确的 policy,再按需放宽。
  • policy 一定用 TO authenticatedTO 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 以 anonauthenticated 运行、受 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 看到的结果。

相关阅读

标签: #后端 #排查 #排查 #Supabase