メインコンテンツへスキップ
友田 陽大
アプリ層セキュリティ
Supabase
RLS
PostgreSQL
セキュリティ
アーキテクチャ設計

Supabase RLSの設定ミスを検出する — 未有効化・WITH CHECK欠落・USING(true)・anon過剰付与をmigrationsから洗い出す

Supabase RLSは『有効化したつもり』で穴が開く。RLS未有効化・WITH CHECK欠落・USING(true)・anon過剰付与・search_path未固定のSECURITY DEFINERという危険パターンを、supabase/migrations/**.sqlの静的検証で洗い出して塞ぐ実践ガイドです。

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

最初に結論を述べます。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つあります。

  1. RLSの有効化は fail-secure。 RLSを有効化してポリシーが1つも無ければ、デフォルトは「全拒否」です(PostgreSQL: Row Security Policies)。「とりあえず enable だけ」しておけば、少なくとも無防備な全件公開は止まります。検出ツールが真っ先に拾うべきはこの「enable が存在しないテーブル」です。
  2. auth.uid()(select auth.uid()) で包む。 Supabase公式が推奨する書き方で、行ごとに関数を再評価せず初期計画でキャッシュさせるため、大きなテーブルで性能が桁違いに変わります(Supabase: Row Level Security)。正しく動くのに遅い、という罠を避けられます。
  3. テーブルの所有者は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で最も誤解されているポイントです。読み取りは完璧にロックされているのに、書き込みは無防備、という状態が普通に発生します。原因は USINGWITH CHECK の役割を混同することです。

USINGWITH 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つです。

  1. なりすまし挿入:ユーザーが user_id を他人のIDにしてINSERTし、他人名義のデータを植え付ける/他テナントを汚染する。
  2. 所有権の書き換え:UPDATEで自分の行の owner_id を他人に書き換える、あるいは roleadmin に昇格させる。

脆弱な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 ALLFOR 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 INSERTWITH 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つの経路があります。

  1. ポリシーで anon を対象にするto anon、または TO 省略で PUBLIC に含めてしまう)
  2. テーブル権限を 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 anonTO 省略のポリシー」と「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 UPDATEWITH 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 CHECKINSERT/UPDATEで新しい行の所有者を検査しているかfor insertwith check が無い/true
TO 句ポリシーが to authenticated 等で対象ロールを明示しているかTO 省略で anon まで開いている
anon権限機微テーブルへの grant ... to anon が無いか個人情報テーブルが anon にGRANTされている
SECURITY DEFINERset search_path を固定し、本人性を関数内で強制しているかsearch_path 未固定/anonexecute 付与
検証の自動化スキャン・回帰テストが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 UPDATEWITH 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棚卸しは、前節で触れたセキュリティ監査でお手伝いします。お気軽にご相談ください。


参考資料

友田

友田 陽大

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

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

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

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

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