結論から書きます。Supabaseで「全ログインユーザーに他人のデータが見えてしまう」原因の多くは、RLS の"有無"ではなく、ポリシー述語の"意味"にあります。 RLSはちゃんと有効で、ポリシーもある。それでも using (auth.role() = 'authenticated') と書いてあると、そのテーブルは「ログインしていれば誰でも全行を読める」状態になります。ポリシーが認証(あなたは誰か=ログイン済みか)は確かめているのに、認可(この行はあなたのものか)を確かめていない——これがこの記事のテーマです。
これは想像上のバグではありません。公開されているSupabaseアプリを1,000件、静的解析でスキャンした実態調査(RLSポリシー116,662件)では、RLSを使っている994件のうち9.2%が少なくとも1つ、この「認証はするが認可しない」ポリシーを持っていました。235件の指摘を1件ずつ手で監査し、precision 1.0(残存誤検知ゼロ)で確かめた下限値です。つまり、実際にはもっと多い可能性が高い。
先に強調しておきます。これは「AIを使うな」「Supabaseは危険だ」という話ではありません。RLSは強力で、Supabaseは堅牢です。問題は、認可という"垂直リスク"が、AIにも開発者にも『動いたからOK』で見過ごされやすいという一点にあります。以下、なぜこの述語で全行が漏れるのか、なぜ量産されるのか、そして「認証のみ」が正当な共有テーブルとどう見分け、どう直すのかを、一次情報と実SQLで解説します。
1. 「認証」と「認可」は述語のどこで分かれるか
まず言葉を厳密に固定します。
- 認証(Authentication) = 「あなたが誰か」を確かめること。ログインがこれ。
- 認可(Authorization) = 「あなたがこの操作・このデータに対する権限を持つか」を確かめること。
RLSポリシーの USING 式は、本来この認可を書く場所です。ところが、次の3つは見た目が似ていて、意味がまったく違います。
-- ① 認証のみ(ログイン済みか?)— 行を絞っていない
using ( auth.role() = 'authenticated' )
-- ② 認証のみ(ユーザーIDが存在するか?)— ①と同じ罠
using ( auth.uid() is not null )
-- ③ 認可(この行は本人のものか?)— 所有者に束縛している
using ( (select auth.uid()) = user_id )
①と②はセッションの存在証明にすぎません。「ログインしているか?」に真偽で答えるだけで、user_id 列を一度も参照していない。つまり**どの行に対しても同じ答え(ログイン中なら常に真)**を返します。結果として、認証ユーザー全員に全行が開きます。
③だけが、auth.uid()(=リクエストしている本人のID)を行の所有列 user_id と突き合わせています。これが認可です。RLSは各行についてこの式を評価し、真の行だけを返す。だからIDを書き換えても他人の行は0件になります。
to authenticatedは"適用する相手"であって"返す行"ではない。create policy ... to authenticatedは「このポリシーを authenticated ロールに 適用する」という宣言で、行のフィルタはUSING式が決めます。to authenticated using (auth.role() = 'authenticated')は「ログインユーザーに対して、ログインしていれば全部見せる」という意味になり、二重に"認証だけ"を言っているのと同じです。ここが最も誤解されるポイントです(PostgreSQL: Row Security Policies)。
この「認証はするが認可しない」という欠落は、IDOR(コード側で所有権チェックを忘れるクラス)と兄弟関係にあります。IDORがアプリコードの .eq("user_id", …) 忘れでRLSの"外"に開くのに対し、こちらはRLSポリシーの述語そのものが認可を放棄している。守るべき境界は同じ「所有権」ですが、漏れる場所が違います。
2. なぜ auth.role() = 'authenticated' で全行が漏れるのか
Supabaseは、PostgreSQLのテーブルを PostgREST 経由でREST APIとして自動公開します。ここで、次のような一見それっぽいスキーマを考えます。
create table public.notes (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users, -- ← 所有者を示す列がある
body text,
created_at timestamptz not null default now()
);
-- RLSは有効化している(ここは正しい)
alter table public.notes enable row level security;
-- だが、ポリシーが「認証のみ」
create policy "authenticated can read notes"
on public.notes for select
to authenticated
using ( auth.role() = 'authenticated' ); -- ← ログイン済みかしか見ていない
RLSは有効。ポリシーもある。Supabaseのダッシュボードの警告(RLS未設定)にも引っかからない。それでも、これは全ユーザーのメモが漏れます。
# 攻撃者は特別なことを何もしていない。自分のJWTでログインし、そのまま全件取得するだけ。
curl "https://<project>.supabase.co/rest/v1/notes?select=*" \
-H "apikey: <anon-key>" \
-H "Authorization: Bearer <任意のログインユーザーのJWT>"
# → 自分以外のユーザーの user_id と body が、全行そのまま返ってくる
理由は単純です。ログインしているユーザーの auth.role() は常に 'authenticated'。だから USING 式はすべての行で真になり、PostgRESTはテーブル全体を返します。user_id 列は存在するのに、ポリシーは一度もそれを見ていない。
auth.uid() is not null に書き換えても同じです。ログイン中なら auth.uid() は常に非NULL。やはり全行で真になります。
この漏洩クラスの現実の重さは、公開CVEにも表れています。CVE-2025-48757(CVSS 9.3 CRITICAL, CWE-863 Incorrect Authorization)は、AI生成サイト群でRLS/認可が不十分だったために任意テーブルの読み書きが可能になった事例です。分類はまさに A01:2021 Broken Access Control(OWASP)——OWASP Top 10 の1位です。
3. なぜAIと開発者がこのポリシーを量産するのか
このパターンが「うっかり」で終わらず繰り返し量産されるのには、構造的な理由があります。
理由1:プロンプトの字義的な実装。 「ログインしたユーザーだけがメモを読めるようにして」と頼むと、AIも人も素直に「ログインしているか」を条件に書きます。auth.role() = 'authenticated' は、その日本語をほぼそのままSQLにした形です。「本人の行だけ」という言外の要件は、明示しない限り出力に現れません。
理由2:デモでは絶対に露見しない。 開発中は自分のアカウントしか使いません。自分のメモしか作っていないので、全件返っても「自分のメモが全部見える」だけ。IDを書き換えて他人を覗く操作を誰もしないため、テストは緑のまま本番へ行きます。
理由3:to authenticated の存在が誤った安心を生む。 公式チュートリアルは to authenticated でロールを絞ることを(正しく)勧めます。しかし「authenticated に絞った」ことと「行を本人に絞った」ことは別物です。前者だけで安心してしまう。
AIに「安全に書いて」と頼めば直る? 期待しすぎない方がよい。 Veracodeの2025年の調査では、モデルが賢くなっても生成コードのセキュリティ評点は横ばいでした(2025 GenAI Code Security Report)。AIは「動くコード」の生成には強いが、「壊れない構造」を保証しません。速度は活かしつつ、**検証ゲート(スキャン・テスト・レビュー)**を必ず通す前提で使うのが正解です。
つまり、これは"注意力"の問題ではなく、露見しない構造の問題です。だからこそ、注意ではなく機械的な検出で潰すのが効きます(§7)。
4. 「認証のみ」が必ずしもバグではない — 共有テーブルとの見分け方
ここが、この記事でいちばん誠実に書きたい部分です。「所有者スコープでない=脆弱」ではありません。 authenticated-only が正当なテーブルは実在します。
-- 参照テーブル:全ログインユーザーが読めて良い(バグではない)
create table public.countries ( code text primary key, name text not null );
alter table public.countries enable row level security;
create policy "all authenticated can read countries"
on public.countries for select
to authenticated
using ( true ); -- または auth.role() = 'authenticated'。共有マスタなので正当
国一覧・カテゴリ・機能フラグ・公開記事のような共有参照データは、全員が読めて構いません。ここに auth.uid() = user_id を書く方が、むしろ間違いです(誰も読めなくなる)。
では、どう見分けるか。判断軸はシンプルです。
| 問い | Yes寄り → 所有者スコープすべき | No寄り → 認証のみで妥当 |
|---|---|---|
所有者/テナントを示す列があるか(user_id, owner_id, tenant_id, org_id, account_id) | ある | ない |
| 各行はユーザー固有の資産か | メモ・注文・メッセージ・書類・請求書 | 国・カテゴリ・タグ・公開告知 |
| 他ユーザーにその行が見えて困るか | 困る(個人情報・機微) | 困らない(公共の参照) |
| 書き込みは本人だけに限りたいか | 限りたい | 誰も書かない(管理者が投入) |
所有列があるのに認証しか見ていない——この組み合わせが、確認すべき危険信号です。だからこそ、この所見は「脆弱性の断定」ではなく medium・非ブロッキング・"意図を確認せよ" として扱うのが正しい。前掲の実態調査が「9.2%」を"漏洩の断定"ではなく"要確認"として報告しているのは、この誠実さを守るためです。
5. 所有者スコープの正しい書き方(修正カタログ)
危険信号だと分かったら、述語を所有者に束縛します。ケース別に置いておきます。
5.1 基本:本人の行だけ読む・書く
-- 読み取り:本人の行だけ
create policy "owners read their notes"
on public.notes for select
to authenticated
using ( (select auth.uid()) = user_id );
-- 追加:本人のIDでしか作れない(WITH CHECK が必須)
create policy "owners insert their notes"
on public.notes for insert
to authenticated
with check ( (select auth.uid()) = user_id );
-- 更新:見えている行を、本人のIDのまま更新(USING と WITH CHECK の両方)
create policy "owners update their notes"
on public.notes for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );
ポイントは2つ。
auth.uid()を(select auth.uid())で包む。 これはSupabase公式が推奨する性能テクニックで、関数を行ごとに再評価せず初期プランで一度だけ評価(キャッシュ)します。大きなテーブルで桁違いに速くなります。素のauth.uid() = user_idは「正しいが遅い」罠にはまりがちです(Supabase: Row Level Security、詳細はRLSの性能最適化)。- 書き込みには
WITH CHECKを対で書く。USINGは「見える行」、WITH CHECKは「書ける値」。これを忘れると、本人になりすましたuser_idで他人の行を作れる書き込みバイパスが開きます。
5.2 マルチテナント:所属で絞る
個人ではなく組織単位のときは、所属(membership)を経由します。
-- notes.org_id が「自分の所属組織のどれか」に含まれる行だけ
create policy "members read org notes"
on public.notes for select
to authenticated
using (
org_id in (
select org_id from public.memberships
where user_id = (select auth.uid())
)
);
テナント分離は所有者スコープの一般化です。設計パターンはマルチテナントのRLS設計にまとめています。
5.3 「認可しているつもり」で紛らわしいが正しい形
次の述語は、素朴なスキャナが「認証のみ」と誤検知しやすいが、実際には正しく認可している形です(前掲調査で誤検知クラスとして潰したもの)。自分のポリシーがこれに当たるなら、直す必要はありません。
-- ① 参加者束縛:送受信者のどちらかが本人(DM等)— 正当
using ( (select auth.uid()) in (sender_id, receiver_id) )
-- ② 大小文字非依存のメール束縛 — 正当
using ( lower(email) = lower((select auth.jwt()) ->> 'email') )
-- ③ service_role ゲート(バックエンド専用)— 一般ユーザーには無関係、正当
using ( auth.role() = 'service_role' )
-- ④ RBAC/クレーム委譲(別レイヤの認可)— 認証のみとは別物
using ( (select auth.jwt()) ->> 'user_role' = 'admin' )
①②は本人を行に束縛しているので認可済み。③はバックエンド専用で、そもそも service_role はRLSをバイパスするため一般ユーザーの経路には無関係。④はロール/クレームへ委譲した別レイヤの認可で、認証のみの穴とは異なります(RBACの設計)。
6. 機械で見つける — 貼るだけチェッカー / CLI / pgTAP
「うちのポリシーは大丈夫か?」を注意力で毎回確かめるのは持続しません。3つの手段で機械化します。
手段1:ブラウザで貼るだけ(インストール不要・SQLは外に出ない)
手元のポリシーを1本、貼って判定したいだけなら、ブラウザ内で完結する無料のRLSチェッカーが最短です。using (auth.role() = 'authenticated') を貼れば「認証のみ(要確認)」、using ((select auth.uid()) = user_id) を貼れば「所有者スコープ(OK)」と即座に分類します。判定は100%ブラウザ内で走り、SQLはどこにも送信しません。
手段2:リポジトリ全体をスキャン
supabase/migrations/**.sql を横断して、このパターンを含む全ポリシーを洗い出します。
# インストール不要・設定不要。ローカルで走り、どこにも通信しない。
npx @aegiskit/cli scan
これは前掲の実態調査でも使ったOSSスキャナ Aegis の rls/policy-not-owner-scoped ルールで、「所有列を持つテーブルで、セッション存在だけを証明して行を絞っていない」ポリシーを指摘します(検出の詳細)。
手段3:pgTAPで回帰テストにする
見つけて直したら、二度と退行しないように allow/deny をテスト化します。ユーザーAでログインした状態でユーザーBの行が0件になることを、CIで継続的に証明します(pgTAPでのRLS回帰テスト)。
正直なスコープ — ツールは「形」を見る。「意図」は人が見る
ここは崩してはいけません。どんな静的・動的ツールも、あなたの認可が"正しい"ことは証明できません。 ツールが見るのは述語の"形"であって、業務ルールやデータモデルの"意味"ではない。「このテーブルは本当に共有参照データか?」「テナント分離は設計として正しいか?」「権限昇格の余地はないか?」——ここは人間のレビュー・監査でしか担保できません。スキャンが0件でも「よくある罠を踏んでいない」であって「認可が正しい」ではない。ツールは検出を機械化して人を"難しい判断"に集中させる補完であり、設計と review の代替ではありません。
7. チームとクライアントのためのチェックリスト
外注コードでも、AIに書かせたコードでも、本番前に最低限これだけは確認してください。非エンジニアでも判断できる形にしています。
| 観点 | 確認すること | 危険信号 |
|---|---|---|
| 所有列の有無 | 個人/テナント固有のテーブルに user_id 等の所有列があるか | 所有列があるのに USING が auth.role()='authenticated' |
| 認証 vs 認可 | USING が本人を行に束縛しているか(= user_id 等) | auth.uid() is not null / auth.role()='authenticated' だけ |
| 書き込みの WITH CHECK | INSERT/UPDATE に WITH CHECK があるか | 書き込みポリシーに WITH CHECK が無い |
| 共有テーブルの意図 | 認証のみのテーブルは"共有参照データ"だと説明できるか | 「なんとなく authenticated にした」 |
| ID書き換えテスト | 2アカウントで、他人のIDを叩くと0件/拒否になるか | 他人のデータが200で返る |
| 検証の自動化 | スキャン・pgTAP・回帰テストがCIにあるか | 「自分の環境では動いた」以外の根拠が無い |
クライアント視点で最も効くのは、**「他人のIDを叩いたらどうなりますか?」**という一問です。即答できないなら、認可の検証が設計に組み込まれていない可能性が高い。良い開発者はこれに即答できます。
この「RLSはあるのに認可が抜ける」クラスは、私が自作のOSSセキュリティツール Aegis(npx @aegiskit/cli scan)で検出対象にしている中核パターンであり、受託・自社開発の両方で実際に塞いできた領域です。AIで速く作ること自体は正しい。速く作ったものを、漏れなく安全に固めること——その検証の仕組みづくりや、既存のNext.js × Supabaseアプリの認可レビュー・監査が必要でしたら、お気軽にご相談ください。