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

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

- 公開日: 2026-07-03
- 著者: 友田 陽大
- タグ: Supabase, RLS, PostgreSQL, セキュリティ, Next.js
- URL: https://tomodahinata.com/blog/supabase-rls-authenticated-vs-authorized-owner-scope-guide
- カテゴリ: アプリ層セキュリティ
- 総合ガイド: https://tomodahinata.com/blog/nextjs-supabase-application-security-guide

## 要点

- using (auth.role() = 'authenticated') や using (auth.uid() is not null) は『認証』（ログイン済みか）だけを確かめ、『認可』（その行が本人のものか）を確かめない。所有列を持つテーブルでこれを使うと、全認証ユーザーが全行を読めてしまう。
- これは机上の話ではない。公開Supabaseアプリ1,000件（RLSポリシー116,662件）を静的解析した実態調査で、RLSを使う994件の9.2%が少なくとも1つこのパターンを持っていた（下限値・235件を手動で全件監査しprecision 1.0）。
- ただし『所有者スコープでない＝脆弱』ではない。国一覧やカテゴリなど共有参照テーブルなら authenticated-only は正当。判断軸は『その行はユーザー固有の資産か、全員で共有する参照データか』。
- 直し方は述語を所有者に束縛すること：using ((select auth.uid()) = user_id)。書き込みには対の with check を必ず添える。select ラップは行ごとの再評価を避ける Supabase 公式推奨の性能テクニック。
- 検出は貼るだけのブラウザRLSチェッカーとリポジトリ全体の npx @aegiskit/cli scan で機械化できる。ただしツールは述語の『形』を見るだけで、共有意図の正しさや業務ルールの妥当性は人間のレビュー・監査に委ねられる。

---

結論から書きます。**Supabaseで「全ログインユーザーに他人のデータが見えてしまう」原因の多くは、RLS の"有無"ではなく、ポリシー述語の"意味"にあります。** RLSはちゃんと有効で、ポリシーもある。それでも `using (auth.role() = 'authenticated')` と書いてあると、そのテーブルは「ログインしていれば誰でも全行を読める」状態になります。ポリシーが**認証（あなたは誰か＝ログイン済みか）は確かめているのに、認可（この行はあなたのものか）を確かめていない**——これがこの記事のテーマです。

これは想像上のバグではありません。公開されているSupabaseアプリを1,000件、静的解析でスキャンした[実態調査（RLSポリシー116,662件）](/blog/supabase-rls-security-field-study)では、RLSを使っている994件のうち**9.2%が少なくとも1つ、この「認証はするが認可しない」ポリシー**を持っていました。235件の指摘を1件ずつ手で監査し、precision 1.0（残存誤検知ゼロ）で確かめた**下限値**です。つまり、実際にはもっと多い可能性が高い。

先に強調しておきます。これは「AIを使うな」「Supabaseは危険だ」という話ではありません。RLSは強力で、Supabaseは堅牢です。問題は、**認可という"垂直リスク"が、AIにも開発者にも『動いたからOK』で見過ごされやすい**という一点にあります。以下、なぜこの述語で全行が漏れるのか、なぜ量産されるのか、そして「認証のみ」が正当な共有テーブルとどう見分け、どう直すのかを、一次情報と実SQLで解説します。

---

## 1. 「認証」と「認可」は述語のどこで分かれるか

まず言葉を厳密に固定します。

- **認証（Authentication）** = 「あなたが誰か」を確かめること。ログインがこれ。
- **認可（Authorization）** = 「あなたがこの操作・このデータに対する権限を持つか」を確かめること。

RLSポリシーの `USING` 式は、本来この**認可**を書く場所です。ところが、次の3つは見た目が似ていて、意味がまったく違います。

```sql
-- ① 認証のみ（ログイン済みか？）— 行を絞っていない
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](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)）。

この「認証はするが認可しない」という欠落は、[IDOR（コード側で所有権チェックを忘れるクラス）](/blog/nextjs-supabase-idor-broken-authorization-rls-detection-guide)と兄弟関係にあります。IDORが**アプリコードの `.eq("user_id", …)` 忘れ**でRLSの"外"に開くのに対し、こちらは**RLSポリシーの述語そのもの**が認可を放棄している。守るべき境界は同じ「所有権」ですが、漏れる場所が違います。

---

## 2. なぜ `auth.role() = 'authenticated'` で全行が漏れるのか

Supabaseは、PostgreSQLのテーブルを **PostgREST** 経由でREST APIとして自動公開します。ここで、次のような一見それっぽいスキーマを考えます。

```sql
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未設定）にも引っかからない。**それでも、これは全ユーザーのメモが漏れます。**

```bash
# 攻撃者は特別なことを何もしていない。自分の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](https://nvd.nist.gov/vuln/detail/CVE-2025-48757)**（CVSS 9.3 CRITICAL, CWE-863 Incorrect Authorization）は、AI生成サイト群でRLS/認可が不十分だったために任意テーブルの読み書きが可能になった事例です。分類はまさに **A01:2021 Broken Access Control**([OWASP](https://owasp.org/Top10/A01_2021-Broken_Access_Control/))——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](https://www.veracode.com/resources/analyst-reports/2025-genai-code-security-report/)）。AIは「動くコード」の生成には強いが、「壊れない構造」を保証しません。速度は活かしつつ、**検証ゲート（スキャン・テスト・レビュー）**を必ず通す前提で使うのが正解です。

つまり、これは"注意力"の問題ではなく、**露見しない構造**の問題です。だからこそ、注意ではなく**機械的な検出**で潰すのが効きます（§7）。

---

## 4. 「認証のみ」が必ずしもバグではない — 共有テーブルとの見分け方

ここが、この記事でいちばん誠実に書きたい部分です。**「所有者スコープでない＝脆弱」ではありません。** `authenticated-only` が**正当**なテーブルは実在します。

```sql
-- 参照テーブル：全ログインユーザーが読めて良い（バグではない）
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 基本：本人の行だけ読む・書く

```sql
-- 読み取り：本人の行だけ
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](https://supabase.com/docs/guides/database/postgres/row-level-security)、詳細は[RLSの性能最適化](/blog/supabase-rls-performance-optimization-select-wrap-index-guide)）。
2. **書き込みには `WITH CHECK` を対で書く。** `USING` は「見える行」、`WITH CHECK` は「書ける値」。これを忘れると、本人になりすました `user_id` で他人の行を作れる[書き込みバイパス](/blog/supabase-rls-with-check-using-write-bypass-guide)が開きます。

### 5.2 マルチテナント：所属で絞る

個人ではなく組織単位のときは、所属（membership）を経由します。

```sql
-- 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設計](/blog/supabase-rls-production-multi-tenancy-patterns)にまとめています。

### 5.3 「認可しているつもり」で紛らわしいが正しい形

次の述語は、素朴なスキャナが「認証のみ」と誤検知しやすいが、**実際には正しく認可している**形です（前掲調査で誤検知クラスとして潰したもの）。自分のポリシーがこれに当たるなら、直す必要はありません。

```sql
-- ① 参加者束縛：送受信者のどちらかが本人（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をバイパス](/blog/supabase-anon-key-service-role-key-exposure-guide)するため一般ユーザーの経路には無関係。④はロール/クレームへ委譲した別レイヤの認可で、認証のみの穴とは異なります（[RBACの設計](/blog/supabase-rls-rbac-custom-claims-app-metadata-authorize-guide)）。

---

## 6. 機械で見つける — 貼るだけチェッカー / CLI / pgTAP

「うちのポリシーは大丈夫か？」を注意力で毎回確かめるのは持続しません。3つの手段で機械化します。

### 手段1：ブラウザで貼るだけ（インストール不要・SQLは外に出ない）

手元のポリシーを1本、貼って判定したいだけなら、ブラウザ内で完結する[無料のRLSチェッカー](/aegis/rls-checker)が最短です。`using (auth.role() = 'authenticated')` を貼れば「認証のみ（要確認）」、`using ((select auth.uid()) = user_id)` を貼れば「所有者スコープ（OK）」と即座に分類します。**判定は100%ブラウザ内で走り、SQLはどこにも送信しません。**

### 手段2：リポジトリ全体をスキャン

`supabase/migrations/**.sql` を横断して、このパターンを含む全ポリシーを洗い出します。

```bash
# インストール不要・設定不要。ローカルで走り、どこにも通信しない。
npx @aegiskit/cli scan
```

これは前掲の実態調査でも使ったOSSスキャナ [Aegis](/aegis) の `rls/policy-not-owner-scoped` ルールで、「所有列を持つテーブルで、セッション存在だけを証明して行を絞っていない」ポリシーを指摘します（[検出の詳細](/blog/supabase-rls-misconfiguration-detection-audit-guide)）。

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

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

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

ここは崩してはいけません。**どんな静的・動的ツールも、あなたの認可が"正しい"ことは証明できません。** ツールが見るのは述語の"形"であって、業務ルールやデータモデルの"意味"ではない。「このテーブルは本当に共有参照データか？」「テナント分離は設計として正しいか？」「権限昇格の余地はないか？」——ここは人間の[レビュー・監査](/blog/nextjs-supabase-security-audit-scope-when-needed-guide)でしか担保できません。スキャンが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](/aegis)（`npx @aegiskit/cli scan`）で検出対象にしている中核パターンであり、受託・自社開発の両方で実際に塞いできた領域です。AIで速く作ること自体は正しい。**速く作ったものを、漏れなく安全に固めること**——その検証の仕組みづくりや、既存のNext.js × Supabaseアプリの[認可レビュー・監査](/aegis/audit)が必要でしたら、お気軽にご相談ください。
