最初に結論を述べます。Supabaseの行レベルセキュリティ(RLS)は「設定したつもり」で穴が開きます。 しかも、その穴の開き方は無限のバリエーションがあるわけではなく、有限の決まったパターンに収束します——RLSの有効化忘れ、USING (true) のような無条件許可、WITH CHECK の欠落、anon ロールへの過剰な権限付与、search_path を固定しない SECURITY DEFINER 関数。この5つです。
そして重要なのは、これらは supabase/migrations/**.sql を静的に読むだけで、機械的に洗い出せるということです。脆弱性スキャンというと「実際に攻撃して試す」イメージがあるかもしれませんが、RLSの設定ミスの大半は、SQLのDDL(テーブル・ポリシー・関数の定義)を構文的に解析した時点で「これは危ない」と判定できます。本記事は、その危険パターンのカタログと、なぜそれが量産されるのか、そしてどうやってmigrationsから自動検出するのかを、脆弱→修正の実SQLで示します。
ただし、最後に正直な線引きをします。「検出」は機械化できても、「正しいRLS設計」は機械化できません。 誰が何を所有し、テナントの境界をどこに引くか——これはあなたの事業ドメインに固有の設計判断であり、人間の監査領域です。ツールが言えるのは「よくある罠を踏んでいない」までで、「あなたの認可が正しい」ではない。ここを混同しないことが、この記事で一番伝えたいことです。
なお本記事は認可(authorization)まわりの一連の解説の一部です。Next.js × Supabase全体のセキュリティ像は総合ガイドに、鍵の取り扱いはanonキー / service_roleキーの露出に、IDキーを書き換えて他人のデータを読むIDOR/BOLAにそれぞれ分けてあります。本記事は「RLSポリシーそのものの設定ミス」に絞ります。
1. 前提:Supabaseは「テーブルを作る」と「APIを公開する」が同義
RLSの設定ミスがなぜ致命的なのかを理解するには、Supabaseのアーキテクチャを1つだけ押さえる必要があります。
Supabaseは、public スキーマのテーブルを PostgREST 経由で自動的にREST APIとして公開します。 あなたがテーブルを1つ作った瞬間、https://<project>.supabase.co/rest/v1/<table> というエンドポイントが生えます。そしてこのAPIは、ブラウザに配られる anon キー で叩けます。anon キーは秘密情報ではなく、クライアントに埋め込まれる公開鍵です(Supabase: API keys)。
つまり、こういう因果になります。
CREATE TABLE しただけ → REST API が自動で生える
RLS を有効化していない → anon キーでそのAPIが素通り
anon キーは公開情報 → 実質「誰でも全件読める」
ここで効いてくるのがPostgreSQLの仕様です。RLSはテーブルごとに明示的に有効化しない限り、無効です。 CREATE TABLE はRLSを有効化しません。ALTER TABLE ... ENABLE ROW LEVEL SECURITY を別途実行して初めて行制御が始まります(PostgreSQL: Row Security Policies)。SupabaseのダッシュボードのTable Editorで作るとRLSがデフォルトで有効化されますが、SQLのmigrationで create table を書くと有効化されない——この非対称性が、設定ミスの最大の供給源です。
Supabase公式も「Postgres上に構築するなら、データを保護するためにすべてのテーブルでRLSを有効化すべき」と明記しています(Supabase: Row Level Security)。逆に言えば、有効化は「やってくれるもの」ではなく「あなたが書くもの」です。
この前提のもとで、危険パターンを1つずつ分解します。各パターンは「何が起きるか → 脆弱なSQL → 修正したSQL」の順で示します。
2. 危険パターン①:RLS未有効化(ENABLE ROW LEVEL SECURITY 忘れ)
最も多く、最も致命的なのがこれです。RLSを有効化しないままテーブルを公開してしまう。
何が起きるか
第1節のとおり、RLS無効のテーブルは anon キーで全件読めます。profiles のような個人情報テーブルがこの状態だと、氏名・メール・電話番号・決済顧客IDが、curl 一発で外部に流出します。
脆弱なSQL
-- 危険:RLS を有効化していないテーブル
create table public.profiles (
id uuid primary key default gen_random_uuid(),
user_id uuid references auth.users not null,
full_name text,
phone text,
stripe_customer_id text
);
-- ↑ この migration には enable row level security が無い。
-- 結果、次の1行で全ユーザーの個人情報が返る:
-- curl "https://<project>.supabase.co/rest/v1/profiles?select=*" \
-- -H "apikey: <anon-key>"
create table だけのmigrationは、SQLとしては完全に正しい。型もNOT NULLも外部キーも適切です。「動くこと」と「安全であること」が完全に別問題だと分かる典型例です。
修正したSQL
修正は2段階であることが重要です。有効化(fail-secure化)→ 必要なアクセスだけ明示的に許可 の順です。
-- ステップ1:RLS を有効化する(これ自体が fail-secure =デフォルト全拒否)
alter table public.profiles enable row level security;
-- ステップ2:必要なアクセスだけを明示的に許可する。
-- 有効化しただけでポリシーが0個なら「誰も読めない」が初期状態。
-- そこから「自分の行だけ読める」を足す。
create policy "users read own profile"
on public.profiles
for select
to authenticated -- anon ではなくログイン済みだけに限定
using ( (select auth.uid()) = user_id );
押さえるべきポイントが3つあります。
- RLSの有効化は fail-secure。 RLSを有効化してポリシーが1つも無ければ、デフォルトは「全拒否」です(PostgreSQL: Row Security Policies)。「とりあえず
enableだけ」しておけば、少なくとも無防備な全件公開は止まります。検出ツールが真っ先に拾うべきはこの「enableが存在しないテーブル」です。 auth.uid()は(select auth.uid())で包む。 Supabase公式が推奨する書き方で、行ごとに関数を再評価せず初期計画でキャッシュさせるため、大きなテーブルで性能が桁違いに変わります(Supabase: Row Level Security)。正しく動くのに遅い、という罠を避けられます。- テーブルの所有者はRLSをバイパスする。 PostgreSQLでは、テーブル所有者(Supabaseでは概ね
postgres)は既定でRLSの対象外です。ALTER TABLE ... FORCE ROW LEVEL SECURITYを付けない限り、所有者経由のアクセスは行制御を受けません(PostgreSQL: Row Security Policies)。これは後述のSECURITY DEFINER関数(パターン⑤)の落とし穴に直結します。
3. 危険パターン②:USING (true) / 無条件ポリシー
RLSを有効化し、ポリシーも書いた。だが、その条件が「常に真」になっている——これがパターン②です。
何が起きるか
using (true) は「すべての行を可視にする」という意味です。RLSを有効化した安心感のなかで、実質的に「全拒否」を「全許可」へ戻してしまう。alter table ... enable row level security のログだけ見ると守っているように見えるのに、中身が素通しという、検出が最も価値を持つケースです。
脆弱なSQL
alter table public.documents enable row level security;
-- 危険:条件が true =全行を無条件に許可(RLSを有効化した意味が消える)
create policy "anyone can read documents"
on public.documents
for select
using ( true );
さらに見落とされがちなのが TO 句の省略 です。TO を書かないポリシーは PUBLIC、つまり anon を含む全ロールに適用されます(PostgreSQL: CREATE POLICY)。上のポリシーは to authenticated すら無いので、未ログインの anon でも全 documents が読めます。 using (true) と TO 省略のコンボは、機微テーブルの全公開と同義です。
修正したSQL
-- 所有者の行だけを、ログイン済みユーザーに対して許可する
create policy "owners read their documents"
on public.documents
for select
to authenticated
using ( (select auth.uid()) = owner_id );
ここで正直に線を引きます。using (true) は必ずしもバグではありません。 ブログ記事の公開テーブルや、マスタデータのように「本当に全員が読んでよい」テーブルなら、to anon using (true) は正しい設計です。だから検出ツールは using (true) を**「即・脆弱」と断定するのではなく、「無条件許可。本当に公開意図か?」と人間に確認を促す警告**として出すのが正しい振る舞いです。機械が判定できるのは「無条件である」という構文事実までで、「公開してよいデータか」はドメイン知識です。この線引きは本記事の後半でもう一度強調します。
4. 危険パターン③:WITH CHECK 欠落 — 「読めないが書ける」を作る
ここが、RLSで最も誤解されているポイントです。読み取りは完璧にロックされているのに、書き込みは無防備、という状態が普通に発生します。原因は USING と WITH CHECK の役割を混同することです。
USING と WITH CHECK は別の軸
PostgreSQLのポリシーには2種類の式があります(PostgreSQL: CREATE POLICY)。
| 式 | 何を検査するか | 効くコマンド |
|---|---|---|
USING | 既存の行。どの行が見えるか/更新・削除の対象になれるか | SELECT / UPDATE / DELETE |
WITH CHECK | 新しい行の値。INSERT・UPDATEで書き込もうとする行が許されるか | INSERT / UPDATE |
決定的に重要なのは、INSERT には USING が存在しないことです。新規行には「既存の行」が無いので、INSERT を縛れるのは WITH CHECK だけ。したがって、WITH CHECK を書き忘れた/true にした INSERT ポリシーは、任意の値での書き込みを素通しします。
何が起きるか
具体的な被害は2つです。
- なりすまし挿入:ユーザーが
user_idを他人のIDにしてINSERTし、他人名義のデータを植え付ける/他テナントを汚染する。 - 所有権の書き換え:UPDATEで自分の行の
owner_idを他人に書き換える、あるいはroleをadminに昇格させる。
脆弱なSQL
alter table public.documents enable row level security;
-- 読みは「自分の行だけ」——ここは完璧に見える
create policy "read own"
on public.documents
for select
to authenticated
using ( (select auth.uid()) = owner_id );
-- 危険①:INSERT の検査が true =任意の owner_id で行を作れる(なりすまし挿入)
create policy "insert any"
on public.documents
for insert
to authenticated
with check ( true );
-- 危険②:UPDATE は対象行を自分に絞っているが、新しい行の値を検査しない
-- → 自分の行の owner_id を他人に書き換えられる(所有権の移譲・剥奪)
create policy "update own"
on public.documents
for update
to authenticated
using ( (select auth.uid()) = owner_id )
with check ( true );
この設定の恐ろしさは、SELECTポリシーが完璧なので「RLSは効いている」と錯覚することです。読み取りテストでは他人のデータが見えないので、レビューを通ってしまう。穴は書き込み側に開いています。
修正したSQL
書き込み系のポリシーでは、WITH CHECK に新しい行の所有者が自分であることを必ず要求します。
-- INSERT:作る行の owner_id は必ず自分
create policy "insert own"
on public.documents
for insert
to authenticated
with check ( (select auth.uid()) = owner_id );
-- UPDATE:対象も新しい行も自分に限定(owner_id の書き換えを封じる)
create policy "update own"
on public.documents
for update
to authenticated
using ( (select auth.uid()) = owner_id )
with check ( (select auth.uid()) = owner_id );
検出側の注意:WITH CHECK 省略は「常にバグ」ではない
正確を期すと、FOR ALL や FOR UPDATE のポリシーで WITH CHECK を省略した場合、PostgreSQLは USING の式を WITH CHECK にも流用します(PostgreSQL: CREATE POLICY)。つまり次は安全です。
-- USING がスコープされ、WITH CHECK 省略 → USING が流用されるので書き込みも守られる
create policy "manage own"
on public.documents
for all
to authenticated
using ( (select auth.uid()) = owner_id );
したがって検出ツールが本当に拾うべきは「省略」ではなく、(a) FOR INSERT で WITH CHECK が無い/true、(b) どのコマンドであれ WITH CHECK ( true ) と明示している、(c) そもそも USING 側が using (true) で流用しても無意味——この3つです。「省略=即バグ」と機械的に鳴らすと誤検知(ノイズ)が増え、ツールが信用されなくなります。RLS検証の質は、この種の挙動差をどれだけ正確にモデル化できるかで決まります。
5. 危険パターン④:anon ロールへの過剰なGRANT/ポリシー
Supabaseでは、JWTに応じて2つのデータベースロールが使い分けられます。未ログインの anon と、ログイン済みの authenticated です。PostgRESTはこのロールでクエリを実行し、RLSはそのロールに対して評価されます。
何が起きるか
anon は「インターネット全体」だと思ってください。anon に機微テーブルへのアクセスが渡ると、認証すら不要で漏れます。過剰付与には2つの経路があります。
- ポリシーで
anonを対象にする(to anon、またはTO省略でPUBLICに含めてしまう) - テーブル権限を
anonにGRANTする(grant select on ... to anon)
脆弱なSQL
alter table public.orders enable row level security;
-- 危険①:anon に対して全件可視
create policy "public read orders"
on public.orders
for select
to anon
using ( true );
-- 危険②:TO 省略 → PUBLIC(anon を含む)に適用される
-- 作者は「ログイン済み向け」のつもりでも、anon にも開いてしまう
create policy "read orders"
on public.orders
for select
using ( (select auth.uid()) = customer_id );
-- ↑ anon は auth.uid() が NULL なので結果的に0件だが、
-- ポリシーの対象に anon が含まれること自体が設計意図のズレで、
-- using を true 系に書き換えた瞬間に全公開へ反転する地雷になる
加えて、テーブルそのものの権限付与も見ます。
-- 危険:anon に直接 SELECT 権限を与える(RLS が無効だと即・全公開)
grant select on public.orders to anon;
修正したSQL
機微テーブルは原則 authenticated 起点に限定し、anon への露出は「本当に公開してよいデータ」だけに絞ります。
-- ポリシーは authenticated を明示し、所有権で絞る
create policy "customers read own orders"
on public.orders
for select
to authenticated
using ( (select auth.uid()) = customer_id );
-- anon に渡した過剰な権限は剥がす
revoke select on public.orders from anon;
検出の観点では、「to anon/TO 省略のポリシー」と「anon への GRANT」を機微テーブルに対して洗い出し、公開意図が明示されていないものを警告します。anon キー自体は「RLSと併用される前提で公開してよい鍵」ですが(Supabase: API keys)、それはRLSが正しく効いていればの話です。鍵の安全な置き場所そのものについてはanon/service_roleキーの露出で詳述しています。
6. 危険パターン⑤:search_path 未固定の SECURITY DEFINER 関数
最後は、RLSの「外側」から穴を開けるパターンです。RPC(リモート関数)として公開されるPostgreSQL関数に潜みます。
何が起きるか
SECURITY DEFINER を付けた関数は、呼び出し元ではなく関数の所有者の権限で実行されます(PostgreSQL: CREATE FUNCTION)。Supabaseでは関数の所有者は概ね postgres——つまりテーブル所有者であり、RLSをバイパスするロールです(第2節の3点目)。したがって、SECURITY DEFINER 関数の中のクエリは、RLSを丸ごと無視します。これをPostgREST経由でanon/authenticatedに公開し、かつ関数内で所有権チェックをしていなければ、RLSをいくら張っても素通りされます。
さらに search_path を固定していないと、もう一段危険です。PostgreSQL公式は「SECURITY DEFINER 関数は、信頼できないユーザーが先に検索されるスキーマにオブジェクトを作れると乗っ取られうるため、search_path を安全な値に固定せよ」と明確に警告しています(PostgreSQL: CREATE FUNCTION)。非修飾名(profiles のようにスキーマを付けない参照)が、攻撃者の用意した別オブジェクトに解決され、所有者権限で実行される——権限昇格の温床です。
脆弱なSQL
-- 危険:所有者権限で動くのに、search_path 未固定・所有権チェック無し・anon に開放
create or replace function public.delete_account(target uuid)
returns void
language plpgsql
security definer -- postgres 権限で実行=RLSバイパス
as $$
begin
delete from profiles -- 非修飾名(search_path 次第で別物に解決されうる)
where id = target; -- 呼び出し元が誰かを一切問わない
end;
$$;
-- PostgREST 経由で誰でも呼べてしまう
grant execute on function public.delete_account(uuid) to anon, authenticated;
-- 結果:任意の target を渡せば、他人のアカウントを削除できる
この関数は、RLSが完璧に張られた profiles に対しても、所有者権限で動くため全行を削除できます。 RLSは関係ありません。
修正したSQL
SECURITY DEFINER を使うなら、(1) search_path を固定、(2) オブジェクトは完全修飾、(3) 関数内で呼び出し元の本人性を強制、(4) anon には渡さない——の4点を徹底します。
create or replace function public.delete_my_account()
returns void
language plpgsql
security definer
set search_path = '' -- search_path を固定し、非修飾名の乗っ取りを封じる
as $$
begin
delete from public.profiles -- 完全修飾名で参照する
where id = (select auth.uid()); -- 引数を信じず、呼び出し元本人に限定
end;
$$;
-- 公開範囲を最小化:PUBLIC から剥がし、ログイン済みだけに付与
revoke all on function public.delete_my_account() from public;
grant execute on function public.delete_my_account() to authenticated;
検出の観点はシンプルです。security definer を持つのに set search_path が無い関数を全件挙げ、加えて anon への grant execute を警告する。前者は構文だけで確実に拾えます(search_path の有無は機械判定できる)。後者の「関数内で本人性を強制しているか」は、関数本体のロジックを読む必要があり、ここは半ば人間の領域です。
7. なぜAI生成コードと急いだ開発で量産されるのか
ここまでの5パターンは、知っていれば避けられます。にもかかわらず量産されるのはなぜか。理由は構造的です。
RLSの設定ミスは、デモでは絶対に顕在化しません。 自分のアカウント1つで触っている限り、using (true) でも所有権チェック付きでも、画面の見た目は同じです。WITH CHECK が無くても、自分の行を自分で更新する分には動く。「動いた=正しい」の罠が、認可では特に深い。バグが見えるのは「他人のアカウントが存在し、その境界を踏み越えたとき」だけで、それは開発中にまず起きません。
AIコード生成は、この傾向を増幅します。AIはプロンプトの「やりたいこと(=デモで動くこと)」を最短で実現するコードを書きます。「他人のデータを見せない」「他人になりすませない」は、明示的に要求しない限りハッピーパスの外側で、出力されないか、using (true) のような“とりあえず通る”形になりがちです。
これは抽象論ではなく、現実のインシデントとして記録されています。2025年に登録された CVE-2025-48757 は、その典型です。NVDの記述によれば、AIアプリ生成プラットフォーム Lovable(2025-04-15まで)の不十分なRow-Level Securityポリシーにより、リモートの未認証攻撃者が、生成されたサイトの任意のDBテーブルを読み書きできました。分類は CWE-863(Incorrect Authorization、不正な認可)、CVSS基本値は 9.3 CRITICAL。本記事のパターン①(RLS未有効化)と④(anonへの露出)が、実際に大規模に起きたという証拠です。
そしてこの設定ミスがもたらす被害の典型が、認可の失敗——OWASPがAPIリスクの第1位に挙げる API1:2023 Broken Object Level Authorization(BOLA) です。RLSはこのBOLAを「DB層で」防ぐための主要な防御線であり、その設定が崩れることは、最頻出のAPIリスクに直結します。
要するに、**RLSの設定ミスは「珍しい高度なバグ」ではなく、速さを優先する現代の開発で構造的に生まれる“普通の事故”**です。だからこそ、人間のレビューに加えて、機械的な検出をパイプラインに組み込む価値があります。
8. 検出の自動化:migrationsの静的検証という考え方
5パターンに共通するのは、すべて supabase/migrations/**.sql のDDLに痕跡が残ることです。実際に攻撃しなくても、SQLを読めば判定できる。これが「migrationsの静的検証」です。
単純なgrepでは不十分
注意すべきは、単一ファイルへの grep では誤判定することです。RLSの有効化は、テーブル作成とは別の、後続のmigrationで行われることがあります。enable row level security を「あるファイルに無いから危険」と判定すると、別ファイルで有効化しているケースを誤検知します。正しくは、全migrationを時系列で畳み込み、各テーブルの“最終状態”を再構成する必要があります。
概念モデル
考え方を擬似コードで示します。migrationsを畳み込んで、テーブルごとの状態モデルを作り、危険パターンを照合します。
// migrations を時系列で畳み込んで得る「最終状態」のモデル
type PolicyModel = {
table: string;
command: "select" | "insert" | "update" | "delete" | "all";
roles: string[]; // 空配列 = TO 省略 = PUBLIC(anon を含む)
using: string | null; // 既存行に対する条件
withCheck: string | null; // 新しい行に対する条件
};
type TableModel = {
schema: string;
name: string;
rlsEnabled: boolean; // ALTER TABLE ... ENABLE ROW LEVEL SECURITY を畳み込んだ結果
policies: PolicyModel[];
};
const isTrue = (e: string | null) => e !== null && /^\s*true\s*$/i.test(e);
const facesAnon = (p: PolicyModel) => p.roles.length === 0 || p.roles.includes("anon");
function findings(t: TableModel): string[] {
const out: string[] = [];
// ① RLS未有効化(PostgREST に露出する public スキーマ)
if (t.schema === "public" && !t.rlsEnabled)
out.push("RLS未有効化:anon キーで全件読める可能性");
for (const p of t.policies) {
// ② USING(true) かつ anon/PUBLIC 向け → 無条件公開(要・公開意図の確認)
if (isTrue(p.using) && facesAnon(p))
out.push(`USING(true):${p.command} を無条件許可(本当に公開?)`);
// ③ INSERT は USING を持たない=WITH CHECK だけが頼り
if (p.command === "insert" && (p.withCheck === null || isTrue(p.withCheck)))
out.push("INSERT が無検査:任意の owner_id で行を作れる");
// 明示的な WITH CHECK(true) は、どのコマンドでも新しい行を検査しない
if (isTrue(p.withCheck))
out.push(`WITH CHECK(true):${p.command} で所有者の書き換え/なりすまし可能`);
// ④ anon / PUBLIC 向けポリシー(公開意図が明示されているか要確認)
if (facesAnon(p) && !isTrue(p.using))
out.push(`anon/PUBLIC 対象:${p.command} の露出範囲を確認`);
}
return out;
}
これはあくまで骨子です。実際の検出では、第4節で述べた「FOR ALL/FOR UPDATE は WITH CHECK 省略時に USING を流用する」という挙動差を考慮して誤検知を減らし、SECURITY DEFINER 関数の search_path 有無や anon への GRANT も併せて解析します。
OSSで実行する
この検出を、設定不要で実行できるOSSが Aegis(MITライセンス)です。supabase/migrations を解析して、上記カタログ(①〜⑤)を照合します。
# インストール不要・設定不要。プロジェクト直下で実行する
npx @aegiskit/cli scan
過大な主張はしません。これは「DDLに対するヒューリスティックな静的解析」であって、それ以上でも以下でもありません。正直なスコープは後述の第11節で線を引きます。RLSの設計そのもの——たとえば本番マルチテナンシーのパターンや、ポリシーの回帰を防ぐpgTAPでのテスト、そしてテナント越境の実地検証は、別記事で扱っています。
9. 「検出」できても「正しい設計」は別問題
ここが本記事で最も大切な節です。前節までで「危険パターンは機械検出できる」と述べました。では、検出が0件になればRLSは「正しい」のでしょうか。いいえ。
検出ツールが見ているのは、ポリシーや関数の “形(構文・構造)” です。あなたの事業ルールやデータモデルの “意味” ではありません。具体的に、ツールには原理的に分からないことがあります。
- 所有権モデル:このテーブルの“持ち主”を表す列は
owner_idなのかuser_idなのかcreated_byなのか。auth.uid()と突き合わせるべき列が、本当にその列で正しいのか。 - テナント境界:マルチテナントで、行は
organization_idで隔離すべきなのにuser_idで隔離していないか。組織のメンバー全員が見るべき行を、作成者本人しか見られなくなっていないか(あるいはその逆)。 - 業務ルール:「下書きは本人だけ、公開済みは全員」のような状態に応じた可視性が、ポリシーに正しく反映されているか。
- ロールの設計:
adminは何を越えてよいのか。その“越境”は意図された権限か、それともバグか。
(select auth.uid()) = user_id という構文的に完璧なポリシーが、ドメイン的には間違っていることは普通にあります。たとえば本来は組織単位で共有すべきデータを個人単位に閉じてしまえば、検出は0件でも、要件を満たさない(または別の場所で using (true) に“回避”されて穴が開く)。
だから、**正しい本番RLSの設計目標は「検出が0件になること」**です——これは必要条件であって、十分条件ではありません。検出0件は「よくある罠を踏んでいない」ことの証明であり、「認可が正しい」ことの証明ではない。後者は、所有権モデルとテナント境界を人間が設計し、レビューし、テナント越境の検証やpgTAPの回帰テストで“確定”させて初めて言えます。
テナント境界の設計が事業の生命線になるB2B SaaS——たとえば私が携わった木材流通DXの事例のようなマルチテナント基盤——では、「誰がどのテナントの何を所有するか」の線引きそのものが認可設計の本体でした。ツールはその線引きを速く・漏れなく検証する道具であって、線引きそのものを代行はできません。
10. 本番投入前のRLSチェックリスト
外注でもAI生成でも、本番に出す前に最低限これだけは確かめてください。すべて supabase/migrations/**.sql を読めば判定できます。
| 観点 | 確認すること | 危険信号 |
|---|---|---|
| RLS有効化 | public の全テーブルに enable row level security があるか | create table だけで enable が無い |
| デフォルト拒否 | 有効化したテーブルに、必要なポリシーが揃っているか | 有効化したがポリシー0個で機能が壊れ、using(true) で“回避”されている |
| 無条件許可 | using (true) / with check (true) の公開意図が明確か | 機微テーブルに無条件ポリシーがある |
| WITH CHECK | INSERT/UPDATEで新しい行の所有者を検査しているか | for insert に with check が無い/true |
| TO 句 | ポリシーが to authenticated 等で対象ロールを明示しているか | TO 省略で anon まで開いている |
| anon権限 | 機微テーブルへの grant ... to anon が無いか | 個人情報テーブルが anon にGRANTされている |
| SECURITY DEFINER | set search_path を固定し、本人性を関数内で強制しているか | search_path 未固定/anon に execute 付与 |
| 検証の自動化 | スキャン・回帰テストがCIに組み込まれているか | 「手元で動いた」以外の根拠が無い |
発注者の視点で最も効く質問は2つです。「未ログイン(anon)で叩いたら、どのテーブルが見えますか?」「security definer 関数はいくつあって、それぞれ search_path を固定していますか?」——明確に即答できないなら、RLSの検証が設計に組み込まれていない可能性が高い。
11. 自分でやる範囲と、監査に任せる範囲(正直に)
最後に、現実的な線引きを示します。何を自分でやり、どこから専門家に任せるべきか。
自分でやるべき(=OSSで十分カバーできる)範囲:
- パターン①〜⑤の機械検出。
npx @aegiskit/cli scanをCIに入れ、migrationのたびに自動で回す。これは無料で、今日からできます。 - チェックリストの構文レベルの確認。
enableの有無、with checkの有無、search_pathの有無は、人間がSQLを読んでも拾えます。 - pgTAPでの基本的な回帰テスト。「他人の行が見えない/書けない」を1本書いておく。
監査に任せるべき(=人間の設計判断が要る)範囲:
- 所有権モデルとテナント境界の設計レビュー。
auth.uid()と突き合わせる列が正しいか、組織単位の共有が要件どおりか——ここはあなたの事業を理解した上での判断です。 - 検出0件の“その先”。検出ツールがクリーンでも、ドメイン的に穴がないかは別問題。脅威モデリングと、実際に別テナントになりすまして叩く実地検証が必要です。
- 既存アプリの認可全体の棚卸し。RLSだけでなく、service_role経路・RPC・Storage・外部結合先まで含めた横断レビュー。
この線引きに沿って、OSSのAegisが「検出」を、セキュリティ監査が「設計・実装で実際に塞ぐ」ところを担います。前者は無料で、後者はスポット診断や標準監査として提供しています。「ツールを入れたから安全」とは口が裂けても言いません——その油断こそが最悪の結果を生むからです。ツールは人間の判断を置き換えるのではなく、補完する。最頻出の罠を機械的に潰し、人間が本当に難しい設計判断に集中できるようにする。それが正しい使い方です。
よくある質問(FAQ)
Q. 全テーブルにRLSを有効化すれば、設定ミスは防げますか?
A. パターン①は防げますが、②〜⑤は防げません。using (true) で素通しになる、WITH CHECK が無くて書き込みが無防備、SECURITY DEFINER 関数でRLSを飛び越える——有効化は出発点であって、ゴールではありません。
Q. using (true) は常にバグですか?
A. いいえ。本当に公開してよいデータ(公開記事、マスタデータ等)なら正しい設計です。だから検出は「無条件許可。公開意図か?」という確認の警告として扱うべきで、機械が「即バグ」と断定するのは誤りです。公開意図かどうかはドメイン知識です。
Q. USING だけ書いておけば、書き込みも守られますか?
A. コマンド次第です。FOR ALL/FOR UPDATE は WITH CHECK 省略時に USING を流用するので守られますが、FOR INSERT には USING が無いため、WITH CHECK を書かないと無検査になります。読みと書きは別の軸だと考えてください。
Q. SECURITY DEFINER 関数は使ってはいけませんか?
A. 正当な用途があります(RLSを越える集計や管理処理など)。鉄則は、set search_path を固定し、オブジェクトを完全修飾し、関数内で本人性を強制し、anon には execute を渡さないこと(PostgreSQL: CREATE FUNCTION)。これらを守れない関数は公開しないでください。
Q. 検出が0件なら、RLSは正しいと言えますか? A. 言えません。検出0件は「よくある罠を踏んでいない」ことの証明で、「認可が正しい」ことの証明ではありません。所有権モデルとテナント境界が要件どおりかは、人間の設計判断と実地検証で確かめる必要があります。第9節のとおり、0件は必要条件であって十分条件ではありません。
まとめ:危険パターンは有限、検出は機械化できる。設計は人間がやる
要点を整理します。
- Supabaseは
publicのテーブルを自動でAPI化する。RLS未有効化は「テーブルを作っただけ」で全公開になり、これがCVE-2025-48757で実際に起きたことです。 - RLSの設定ミスは無限ではなく、5つの有限パターン——①未有効化 ②
using (true)等の無条件許可 ③WITH CHECK欠落 ④anonへの過剰付与 ⑤search_path未固定のSECURITY DEFINER——に収束します。 USING(既存の行)とWITH CHECK(新しい行の値)は別の軸。読みが完璧でも書きは無防備、という状態が普通に起きます。- これらは
supabase/migrations/**.sqlを時系列で畳み込んで静的検証すれば機械的に洗い出せます。OSSのnpx @aegiskit/cli scan(MIT)がこれを行います。 - ただし**「検出」と「正しい設計」は別物**。所有権モデルとテナント境界の設計は人間の判断で、検出0件は「罠を踏んでいない」であって「認可が正しい」の証明ではありません。
AIで速く作ること自体は正しい。問題は、速く作ったものの認可を、漏らさず検証する仕組みが無いことです。危険パターンの機械検出は無料のOSS Aegis(npx @aegiskit/cli scan)で今日から始められ、その先の——所有権モデルやテナント境界まで踏み込んだ設計レビューや、既存のSupabaseアプリのRLS棚卸しは、前節で触れたセキュリティ監査でお手伝いします。お気軽にご相談ください。
参考資料
- Supabase Docs — Row Level Security(全テーブルでRLSを有効化すべき/service_roleはRLSをバイパス)
- Supabase Docs — API keys(anonキーは公開鍵、RLS併用が前提)
- PostgreSQL — Row Security Policies(RLSは既定で無効・テーブルごとに有効化/所有者はバイパス)
- PostgreSQL — CREATE POLICY(USING と WITH CHECK の違い/TO 省略は PUBLIC)
- PostgreSQL — CREATE FUNCTION(SECURITY DEFINER と search_path 固定の警告)
- NVD — CVE-2025-48757(不十分なRLSによる未認証の読み書き、CWE-863、CVSS 9.3)
- OWASP API Security Top 10 — API1:2023 Broken Object Level Authorization