본문으로 건너뛰기
友田 陽大
Application-layer security
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で解説します。

Published
Reading time
14 min read
Author
友田 陽大
공유

結論から書きます。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)를 요구사항 정의부터 인프라·운영까지 혼자서 end-to-end로 제공합니다.

The vulnerabilities in this article — is your app safe from them?

An expert audit of your Next.js × Supabase authorization & RLS

The IDOR, RLS misconfigurations, and tenant-boundary crossing covered here are vertical risks a library can't fix. I take it on as a security audit — from authorization review through fix design and implementation. You're welcome to visualize the current state with the free OSS first.

프로젝트 단위(도급)와 기술 자문 모두 가능합니다. 30분 무료 상담부터 시작해 보세요.

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

함께 읽어 보세요