Tatsuya Horikawa
May 26, 2026·Engineering·5 min read

Supabase RLS で『なぜか全件 0 件』が返る時、原因はだいたい2つしかない

RLS を有効化した瞬間、データが空配列で返ってくる。マルチテナント SaaS で踏んだ2つの罠と、それを `can_access_client` 関数1つに集約した設計の話。

Supabase RLS で『なぜか全件 0 件』が返る時、原因はだいたい2つしかない

Supabase で RLS(Row Level Security)を有効化した瞬間、それまで普通に取れていたデータが 全件 0 件 で返ってくる。エラーも出ない。ただ静かに空配列が返ってくる。

マルチテナントの BI プロダクトを Supabase で組んだ時、この罠を2つ続けて踏んだ。 原因を特定するまで2日かかったが、振り返ると 原因はだいたいこの2つしかない と言える。

落とし穴 ①:ポリシーの中で別テーブルを直接 EXISTS している

「ユーザーは自分が紐付いたクライアントのデータだけ見える」というRLSを書くと、 だいたい最初はこう書く。

CREATE POLICY ad_select ON ad_monthly FOR SELECT USING (
  EXISTS (
    SELECT 1 FROM user_clients
    WHERE user_id = auth.uid()
      AND client_id = ad_monthly.client_id
  )
);

これが動かない。user_clients テーブル自身にも RLS を有効化しているからだ。

ポリシーの中で user_clients を読みに行った瞬間、user_clients 側の RLS もかかる。 ユーザー視点で自分の紐付けレコードだけ見える状態のまま判定が走るので、 ある条件下で EXISTS が常に false になる。あるいはポリシー再帰として警告で止まる。

「テーブルにRLSをかける → そのテーブルを参照するポリシーがまた評価される」という 入れ子構造を Postgres は嫌う。

解決:判定を SECURITY DEFINER 関数に逃がす

ロジックを関数1つに切り出して、SECURITY DEFINER を付ける。 これで関数の中だけは RLS をバイパスして user_clients を素直に読める。

CREATE OR REPLACE FUNCTION can_access_client(target UUID)
RETURNS BOOLEAN AS $$
  SELECT EXISTS (
    SELECT 1 FROM user_clients
    WHERE user_id = auth.uid()
      AND (role = 'admin' OR client_id = target)
  );
$$ LANGUAGE sql SECURITY DEFINER;

ポリシーはこれを呼ぶだけになる。

CREATE POLICY ad_select  ON ad_monthly FOR SELECT  USING ( can_access_client(client_id) );
CREATE POLICY ad_insert  ON ad_monthly FOR INSERT  WITH CHECK ( can_access_client(client_id) );
CREATE POLICY ad_update  ON ad_monthly FOR UPDATE  USING ( can_access_client(client_id) );

ポリシーが テーブルごとに違う条件を持たない ようにするのがポイントだ。 新しいテーブルを足す時も、can_access_client(client_id) を貼るだけで終わる。 仕様が変わっても、関数1つ直せば全テーブルに伝播する。

落とし穴 ②:UUID と TEXT の型不一致

落とし穴①を解消した後、別のテーブルを足した時にまた0件問題が再発した。

そのテーブルは外部サービス(Shopify)のIDをそのまま保存する都合で、 client_idTEXT 型 で持っていた。既存テーブルは UUID 型。

ポリシーに can_access_client(client_id) をそのまま貼ると、こうなる。

ERROR: function can_access_client(text) does not exist

Postgres は UUID と TEXT を暗黙キャストしてくれない。 書き換えは1行だけだ。

USING ( can_access_client(client_id::uuid) )

これが見出しの「can_access_client(client_id::uuid) パターン」。 型が違うテーブルでも、キャスト1つで同じ関数を使い回せるのが大事で、 わざわざ別関数を作ったり、ポリシーごとに条件を書き直したりしないで済む。

なぜこの設計に落ち着くか

落とし穴を抜けた先で気づいたのは、RLS は「ポリシー記法」より「設計思想」の問題 だ ということだった。

ポリシー1つ1つに条件を書くと、テーブルが増えるたびに条件が散らばる。 ある時から「role = 'admin' の判定を全テーブルに足す」みたいな改修が出てきて、 何箇所書き換えればいいか分からなくなる

判定を関数1つに集約しておくと、

  • ポリシーは関数を呼ぶだけ
  • 仕様変更は関数の中だけで完結
  • 新しいテーブルを足す時も、同じ1行を貼るだけ

になる。RLS のメンテナンスコストは、ポリシーの数ではなく分岐の数で決まる、 というのが2日詰まった後の結論だ。

RLS は強力だが、間違えるとエラーすら出さずに静かに動かなくなる。 最初にこの形を作っておくと、後から増えるテーブルでも迷わない。 同じところで詰まっている人の助けになれば嬉しい。