メインコンテンツへスキップ
友田 陽大
アプリ層セキュリティ
Supabase
RLS
PostgreSQL
セキュリティ
Next.js

Supabaseの RLS が「認証はするのに認可しない」とき — auth.role() = 'authenticated' が全行を漏らす仕組みと、所有者スコープの正しい書き方

RLSを有効化しポリシーも書いたのに、全ログインユーザーが全行を読めてしまう——auth.role() = 'authenticated' や auth.uid() is not null は「ログイン済みか」を確かめるだけで「その行が本人のものか」を確かめていません。公開Supabaseアプリ1,000件の9.2%が踏んでいたこの『認証はするが認可しない』ポリシーの仕組み、共有テーブルとの正しい見分け方、所有者スコープへの直し方を実SQLで解説します。

公開日
読了時間
14分
著者
友田 陽大
シェア

結論から書きます。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つ。

  1. auth.uid()(select auth.uid()) で包む。 これはSupabase公式が推奨する性能テクニックで、関数を行ごとに再評価せず初期プランで一度だけ評価(キャッシュ)します。大きなテーブルで桁違いに速くなります。素の auth.uid() = user_id は「正しいが遅い」罠にはまりがちです(Supabase: Row Level Security、詳細はRLSの性能最適化)。
  2. 書き込みには 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_roleRLSをバイパスするため一般ユーザーの経路には無関係。④はロール/クレームへ委譲した別レイヤの認可で、認証のみの穴とは異なります(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スキャナ Aegisrls/policy-not-owner-scoped ルールで、「所有列を持つテーブルで、セッション存在だけを証明して行を絞っていない」ポリシーを指摘します(検出の詳細)。

手段3:pgTAPで回帰テストにする

見つけて直したら、二度と退行しないように allow/deny をテスト化します。ユーザーAでログインした状態でユーザーBの行が0件になることを、CIで継続的に証明します(pgTAPでのRLS回帰テスト)。

正直なスコープ — ツールは「形」を見る。「意図」は人が見る

ここは崩してはいけません。どんな静的・動的ツールも、あなたの認可が"正しい"ことは証明できません。 ツールが見るのは述語の"形"であって、業務ルールやデータモデルの"意味"ではない。「このテーブルは本当に共有参照データか?」「テナント分離は設計として正しいか?」「権限昇格の余地はないか?」——ここは人間のレビュー・監査でしか担保できません。スキャンが0件でも「よくある罠を踏んでいない」であって「認可が正しい」ではない。ツールは検出を機械化して人を"難しい判断"に集中させる補完であり、設計と review の代替ではありません。


7. チームとクライアントのためのチェックリスト

外注コードでも、AIに書かせたコードでも、本番前に最低限これだけは確認してください。非エンジニアでも判断できる形にしています。

観点確認すること危険信号
所有列の有無個人/テナント固有のテーブルに user_id 等の所有列があるか所有列があるのに USINGauth.role()='authenticated'
認証 vs 認可USING が本人を行に束縛しているか(= user_id 等)auth.uid() is not null / auth.role()='authenticated' だけ
書き込みの WITH CHECKINSERT/UPDATE に WITH CHECK があるか書き込みポリシーに WITH CHECK が無い
共有テーブルの意図認証のみのテーブルは"共有参照データ"だと説明できるか「なんとなく authenticated にした」
ID書き換えテスト2アカウントで、他人のIDを叩くと0件/拒否になるか他人のデータが200で返る
検証の自動化スキャン・pgTAP・回帰テストがCIにあるか「自分の環境では動いた」以外の根拠が無い

クライアント視点で最も効くのは、**「他人のIDを叩いたらどうなりますか?」**という一問です。即答できないなら、認可の検証が設計に組み込まれていない可能性が高い。良い開発者はこれに即答できます。


この「RLSはあるのに認可が抜ける」クラスは、私が自作のOSSセキュリティツール Aegisnpx @aegiskit/cli scan)で検出対象にしている中核パターンであり、受託・自社開発の両方で実際に塞いできた領域です。AIで速く作ること自体は正しい。速く作ったものを、漏れなく安全に固めること——その検証の仕組みづくりや、既存のNext.js × Supabaseアプリの認可レビュー・監査が必要でしたら、お気軽にご相談ください。

よくある質問

to authenticated を付けているのに、なぜ全ログインユーザーが全行を読めてしまうのですか?
to authenticated は『ポリシーを適用する相手(ロール)』を絞るだけで、『どの行を返すか』は USING 式が決めます。USING が auth.role() = 'authenticated' だと、ログイン済みなら全行で真になるため、認証ユーザー全員に全行が返ります。行を絞るには USING ((select auth.uid()) = user_id) のように所有者へ束縛する必要があります。
auth.role() = 'authenticated' と auth.uid() is not null は何が違いますか?
セキュリティ上は同じ罠です。前者は『ロールが authenticated か』、後者は『ユーザーIDが存在するか』を確かめますが、どちらも『ログインしているか(=セッションの存在)』を証明するだけで、『その行が本人のものか』は一切見ていません。所有列を持つテーブルでどちらを書いても、全認証ユーザーが全行を読めます。
『認証のみ』のポリシーが正しい場合はありますか?
あります。国一覧・カテゴリ・公開記事・機能フラグのような、全ログインユーザーで共有してよい参照データなら authenticated-only(あるいは公開)で妥当です。判断軸は『その行がユーザー固有の資産(メモ・注文・メッセージ)か、全員で共有する参照データか』。所有者を示す列(user_id / tenant_id / org_id)があるのに認証しか見ていない場合は、意図の確認が要ります。
スキャンやチェッカーが0件なら、認可は安全と考えていいですか?
いいえ。0件は『よくある落とし穴を踏んでいない』という意味であって、『認可が正しい』の証明ではありません。ツールが見るのは述語の“形”で、あなたの業務ルールやデータモデルの意味ではありません。共有テーブルの意図の妥当性、テナント分離の正しさ、権限昇格の余地などは、pgTAP等の検証と人間のレビュー・監査でしか担保できません。
既存の大量のポリシーを、どう直せばいいですか?
まず貼るだけのブラウザチェッカーか npx @aegiskit/cli scan で『認証のみ』の述語を持つポリシーを機械的に洗い出します。次に各テーブルを『ユーザー固有の資産か/共有参照データか』で仕分け、前者は USING/WITH CHECK を (select auth.uid()) = user_id 等の所有者束縛に書き換え、pgTAP で allow/deny の回帰テストを足します。件数が多い・テナント設計が絡む場合は、認可レビュー(監査)で優先度と修正方針を一括で固めるのが安全です。

参考文献

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事の脆弱性、あなたのアプリは大丈夫ですか?

Next.js × Supabase の認可・RLS を、専門家が監査します

この記事で扱った IDOR・RLS の設計ミス・テナント越境は、ライブラリでは直せない「縦のリスク」です。認可レビューから修正設計・実装まで、セキュリティ監査として承ります。まず無料の OSS で現状を可視化してからでも構いません。

プロジェクト単位(請負)・技術顧問のどちらにも対応可能です。まずは30分の無料技術相談から。

あわせて読みたい