最初に結論を述べます。公開GitHubにあるSupabaseアプリ450件を静的にスキャンしたところ、RLS(行レベルセキュリティ)を実装している445件のうち8.1%が、『認証はするが認可しない』ポリシ——ログインしてさえいれば誰でも全行を読める設定——を持っていました。 「RLSを有効にしたから安全」と思っていても、その有効化が 認可 になっていない、という落とし穴です。
ただし本記事の主役は、この 8.1% という数字そのものではありません。**むしろ「この数字をどうやって信頼できるものにしたか」**です。同じスキャンを素朴に走らせると、最初は 19.3% という、2倍以上に膨らんだ数字が出ます。その差——生の所見の約83%——は誤検知でした。本記事は、(1) 何をどう測ったか、(2) 8.1%という結果と中核の設計ミス、(3) なぜ多くのスキャナが過大報告し、その誤検知をどう実データで0にしたか、(4) あなたのアプリを今すぐ測る方法、までを一次データで解説します。
これは「Supabaseは危ない」という話でも、「AIを使うな」という話でもありません。速く作ること自体は正しい。速く作ったRLSが、本当に 認可 になっているかを検証する話です。アプリ層セキュリティの全体像はNext.js × Supabase アプリケーションセキュリティ完全ガイドに、個々のRLS危険パターンの修正はSupabase RLSの設定ミス検出ガイドにまとめています。本記事はその主張を裏づける 実測 です。
1. 何を、どう測ったか — 手法と倫理
数字を出す前に、測り方を開示します。測り方を隠した「N件中X%が脆弱」は、信用してはいけません。
- 母集団: GitHubのコード検索で
supabase/migrationsまたはsupabase/schemasにCREATE POLICYを持つ公開リポジトリを 2,230件発見し、そこから 450件を解析しました。この2ディレクトリは、Supabaseがスキーマの真実源とみなす場所です。 - 解析対象: マイグレーションSQL(=権威あるスキーマ定義)のみ。静的解析だけで、デプロイ済みのアプリには一切アクセスしていません(後述の倫理)。
- 指標の定義: 「RLSはあるが、行を所有者に絞らないポリシ」。具体的には、述語が
auth.role() = 'authenticated'やauth.uid() is not nullのように 「セッションが存在すること」しか証明しておらず、auth.uid() = user_idのような所有権の束縛がない PERMISSIVE ポリシ。かつ、そのテーブルに所有者列(user_id等)が存在するものだけを対象にしました。
| 指標 | 値 |
|---|---|
| コード検索で発見した固有リポジトリ | 2,230 |
| 本調査でスキャンしたリポジトリ | 450 |
RLSを実装(CREATE POLICY ≥ 1) | 445 |
| 解析したRLSポリシの総数 | 約 52,800 |
本記事のすべての数字は、コミット済みで再現可能な成果物に紐づいています。調査の方法論・精度ハードニングの6イテレーション・実行スクリプトはすべて公開しており、ご自身で再実行できます。
倫理:公開ソースの静的解析のみ
この種の調査でやってはいけないのは、他人の本番エンドポイントに匿名でアクセスして「データが取れた」と確かめることです。それは不正アクセスのグレーゾーンに踏み込みます。本調査は 公開リポジトリのSQLを読んだだけで、稼働中のアプリには触れていません。リポジトリ名も公開しません(集計値のみ)。日本の不正アクセス禁止法と「許可なき探索はしない」という原則は、ホワイトハッカーの法律ガイドでも触れているとおり、攻撃手法より先に守るべき土台です。
注意:これは「下限」であって「上限」ではない
公開GitHubに マイグレーションをコミットしている 開発者は、相対的に丁寧な層です。最も危ういアプリ——マイグレーションを残さない、リポジトリが非公開——はこの母集団に入りません。つまり真の発生率は、この数字より良くなることはあっても、悪くなる方向に偏っていると考えるのが妥当です。
2. 見出しの数字:8.1%が「認証はするが認可しない」
結果です。RLSを持つ445件のうち、36件(8.1%)が、所有者に絞らないポリシを少なくとも1つ持っていました(該当ポリシは計99件、全RLSポリシの約0.19%)。そして本記事の数字で最も重要な点——この99件は、1件ずつ実SQLで「本物の穴」だと確認済みです(誤検知0)。
中核の誤り:auth.role() = 'authenticated' は認可ではない
最も多かったパターンは、これです。
-- 危険:RLSは有効。だが「ログインしているか」しか確かめていない。
create policy "notes_read" on public.notes
for select to authenticated
using (auth.role() = 'authenticated');
auth.role() = 'authenticated' は、呼び出し元がログイン済みであれば誰でも true になります。ログインしたユーザー全員が、notes テーブルの 全行 を読めてしまう。auth.uid() is not null(セッションが存在することの確認)も同じ穴です。RLSは「有効」なのに、認可が効いていない。
これは机上の懸念ではありません。2025年の CVE-2025-48757(CVSS 9.3 CRITICAL、CWE-863 Incorrect Authorization)は、AI生成プラットフォームの不十分なRLSポリシにより、未認証の攻撃者が任意のテーブルを読み書きできた実例です。本調査の8.1%は、その「一歩手前」——認証は要求するが、行を所有者に絞れていない——のポリシが、いかに普通に存在するかを示しています。
正しい形はこうです。
-- 安全:行を呼び出し元の所有に束縛する。
create policy "notes_read" on public.notes
for select to authenticated
using (auth.uid() = user_id);
なお書き込み側(WITH CHECK)が「ログイン済み」しか見ていないと、USING が正しくても 他人のIDで行を作れる/user_id を他人に書き換えられる(IDORライト)という別の穴になります。これはUSING と WITH CHECK の取り違えによる書き込みバイパスで詳説しています。
誠実な但し書き:「所有者非スコープ=即脆弱」ではない
ここを曖昧にする記事を信用してはいけません。「ログイン済みなら全員が読める」ことが正しいテーブルは存在します。 公開カテゴリ、共有マスタ、チームで共同編集するドキュメント——意図的に共有するテーブルなら、authenticated だけで読ませるのは正当な設計です。
だから、この検出は medium(中)・非ブロッキング の「意図を確認せよ」という警告であって、「脆弱だ」という断定ではありません。8.1%という数字は「8.1%が侵害可能」という意味ではなく、「8.1%に、設計意図の確認を要するポリシがある」という意味です。煽らないことが、数字を信頼に変えます。
3. なぜ8.1%を信頼できるのか — 素朴なスキャナは19.3%と過大報告する
ここからが、本調査で最も価値のある部分です。同じ450件を、素朴な検出器で測ると 19.3%(86件・573所見) という、2倍以上の数字が出ました。その差は実力ではなく 誤検知 です。生の573所見のうち、最終的に本物だったのは99件——約83%が偽陽性でした。
| 段階 | 該当リポジトリ | 所見数 | 内容 |
|---|---|---|---|
| 素朴な検出(生) | 86 / 445 = 19.3% | 573 | このうち約83%が誤検知 |
| 誤検知クラスを実データで除去後 | 36 / 445 = 8.1% | 99 | 全件、実SQLで裏取り(残存誤検知0) |
「精度をベンチマークで測った」と主張するスキャナは多い。だがキュレートされたテスト用fixtureでの "precision 1.0" は、実世界の精度を保証しません。 実際のSupabaseリポジトリには、テスト用fixtureが想定していない「穴に見えるが穴ではない」形が大量にあります。主な誤検知クラスは次のとおりです。
| 誤検知の形(実例) | なぜ誤検知か | 生573所見に占める割合 |
|---|---|---|
auth.role() = 'service_role' | バックエンド専用ロール。RLSを迂回する側で、一般ユーザーは決して満たせない。制限的であって穴ではない | 46.8% |
"auth"."role"() = 'authenticated' | pg_dump/宣言的スキーマが出力する引用符付き識別子。素の auth.role() 前提の検出器は丸ごと取り違える | 多数 |
auth.uid()::text = user_id::text | 型キャスト付きの所有者束縛。正しく絞っているのに「= の左右が一致しない」と誤判定 | 多数 |
(select auth.uid() as uid) = user_id | Supabase公式が推奨する性能向上ラッパ(CLIが自動生成する as uid 別名付き)。推奨パターンを誤検知 | 多数 |
auth.uid() in (sender_id, receiver_id) | チャットの送受信者のような参加者束縛。匿名(uid=null)は満たせない=所有者束縛 | 一部 |
current_setting('role') = 'service_role' / auth.jwt() ? 'service_role' | auth.* 以外/JSONB演算子経由のロール判定。やはり匿名は満たせない | 一部 |
最大の塊である service_role——生の所見の46.8%——は、auth.role() という 文字列 に反応した検出器が、'service_role'(バックエンド専用)と 'authenticated'(本物の穴)を区別できなかった結果です。(select auth.uid() as uid) = user_id に至っては、SupabaseのRLS性能最適化で推奨される書き方そのものを「穴」と誤報していました。推奨パターンを脆弱と報告するツールは、信頼を一度で失います。
これらのクラスを、実データを使って1つずつ特定し、誤検知が0になるまで分類器を作り直しました。判定の中核を「auth.* という単語に言及するか」という曖昧な検出から、「穴とは何か」を肯定的に定義する設計に変えています——穴は auth.uid()/auth.jwt() IS NOT NULL か auth.role() = 'authenticated' のみ。それ以外で auth/ロール/クレームに言及するもの(service_role、JWTクレーム、引用符付き識別子、current_setting)は、匿名が満たせない=穴ではないとして抑制します。
これが、監査人を選ぶときに見るべき一点です。 「全部見つけます」と言うツールや人ではなく、自分の誤検知に正直で、それを実データで潰せるツールや人を選ぶこと。偽陽性でノイズを出すスキャナは、3週間でチームに無視されます。
この精度ハードニングを支えているのが、無料OSSの Aegis です。スキャナの判定ロジックは、実世界の誤検知パターンを回帰テスト(fixture)として固定し、CIで precision = 1.0 をハードフロアとして強制しています。「精度を実測した」という主張が、再現可能な成果物としてコミットされている——これが数字の裏付けです。
4. 副次的な発見(同じ精度では監査していない)
owner-scope以外にも、同じ450件で次の信号が出ました。ただし、これらは8.1%ほど深く誤検知監査をしていないため、参考値として正直に提示します。
- RLS未有効化のテーブルがある: 28.6%(128/448)。
ENABLE ROW LEVEL SECURITYの付け忘れ。比較的「硬い」指標ですが、サンプル検証は要します。 - 匿名で既存行を変更できる露出: 数%。最も「実害」に近く、優先して精査する価値があります。
RLS未有効化や USING (true)、anon への過剰GRANT、SECURITY DEFINER 関数の search_path 未固定といった5つの定番危険パターンは、修正SQL付きでSupabase RLSの設定ミス検出ガイドに、認可を跨ぐIDOR/壊れた認可の検出は別記事にまとめています。
5. あなたのアプリを今すぐ測る
この記事を読んで「うちは大丈夫か?」と思ったら、インストール不要・設定不要で30秒で測れます。本調査と同じ検出を、あなたのリポジトリに対して走らせるだけです。
# プロジェクト直下で。supabase/migrations と TypeScript を静的解析する。
npx @aegiskit/cli scan
CIに組み込むなら、高信頼の所見でビルドを落とし、SARIFをGitHubのコードスキャンに上げられます。
# .github/workflows/security.yml
name: Security
on: [push, pull_request]
jobs:
aegis:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write # SARIF を Security タブへ
steps:
- uses: actions/checkout@v4
- uses: tomodahinata/aegis@v1
with:
severity: HIGH
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: aegis.sarif
CI連携の詳細はGitHub ActionsでのSARIFセキュリティゲートを参照してください。
6. 何が自動で測れて、何が測れないか — 誠実なスコープ
ここは、本調査と同じくらい大事です。いかなる静的解析も、認可の「正しさ」を証明できません(一般には決定不能な問題です)。「全部見つける」と謳うツールは、その瞬間に最悪の結果——偽りの安心——を生みます。
ツールが自動で測れるのは、おおむねここまでです。
- 測れる(高信頼): 注入クラス(SQLi/SSRF/XSS)の「汚染入力→危険シンク」のデータフロー、RLSの述語が所有者に絞っているかの意味解析、ヘッダー/CSP・レート制限・CSRFの水平統制。
- 測れない(設計・監査の領域): 認可の業務的な正しさ(「この共有は 意図 どおりか」)、テナント越えの網羅、多段モジュールを跨ぐデータフロー、ビジネスロジックの破れ。
つまり、本調査の8.1%は「ここに設計意図の確認が要る」という地図であって、修正そのものではありません。修正——『このテーブルは本当に全員に見せていいのか』の判断——は、あなたのデータモデルを知る人間にしかできない。 これが「検出・警告まで自動化、設計修正は人間」という線引きです。どこまでが自動で守れ、どこからが監査が必要になるのかは、別記事で具体的に切り分けています。
7. よくある質問(FAQ)
RLSを有効にすれば、Supabaseは安全ですか?
いいえ。RLSの「有効化」と「認可」は別物です。本調査では、RLSを実装した445件の8.1%が、auth.role() = 'authenticated' のように「ログインしているか」しか確かめず、行を所有者に絞っていませんでした。RLSは 枠 であって、中身(所有者で絞る述語)が認可です。
auth.role() = 'authenticated' は何が問題ですか?
それは「呼び出し元がログイン済みか」しか確かめておらず、ログインしたユーザー全員が全行を読めることを意味します。所有者で絞るには using (auth.uid() = user_id) のように、認証IDを所有者列に束縛します。ただし、意図的に共有するテーブル(公開マスタ等)では authenticated だけで読ませるのは正当です。
この8.1%は「脆弱なアプリの割合」ですか?
違います。「所有者に絞っていないポリシを持つアプリの割合」です。そのうち何件が実際の脆弱性かは、テーブルが共有を意図しているかどうか——人間の設計判断——に依存します。だからこの検出は「脆弱だ」ではなく「意図を確認せよ」という非ブロッキングの警告です。
自分のアプリを無料で調べられますか?
はい。npx @aegiskit/cli scan をプロジェクト直下で実行すると、本調査と同じ検出が走ります。インストールも設定も不要で、デプロイ済みのアプリには一切アクセスしません(静的解析のみ)。
スキャナの「精度が高い」という主張は信用できますか?
テスト用fixtureでの数字だけなら、信用しすぎないでください。本調査が示したとおり、素朴な検出は実世界で19.3%(うち約83%が誤検知)を出します。見るべきは「実世界のリポジトリで誤検知をどう0にしたか」が再現可能な形で開示されているかです。
監査(有料の精査)はどんなときに必要ですか?
ツールの自動検出が届かない領域——認可の業務的な正しさ、テナント越えの網羅、多段の認可フロー——を確かめたいとき、特にエンタープライズ商談・調達のデューデリ・本番投入の直前です。範囲の見極めはセキュリティ監査の範囲ガイドを参照してください。
まとめ:数字より、数字の作り方を信じる
公開Supabaseアプリ450件のうち、RLSを持つ8.1%が「認証はするが認可しない」ポリシを持っていました。だがこの記事の本当の主張は、その数字を信頼に変えるには、19.3%という過大報告を生む誤検知を、実データで1つずつ潰すしかないということです。service_role、引用符付き識別子、型キャスト、性能ラッパ——「穴に見えるが穴ではない」形を区別できて初めて、数字は意味を持ちます。
まず npx @aegiskit/cli scan で自分のアプリを測ってください。そして覚えておいてほしいのは——ツールは「ここを確認せよ」と地図を描くだけで、「正しい」とは言えないということ。RLSが本当に 認可 になっているか、テナント分離が本当に効いているかを、データモデルを知る人間の目で確かめたいときは、Next.js × Supabase アプリケーションセキュリティの全体像と、監査の範囲からご相談ください。速く作る力と、安全に作る力は、同じコインの裏表です。