# テナント越え漏洩を検証する — Supabase RLSの分離を『証明』する方法（設計だけで終わらせない）

> マルチテナントSaaSで他テナントのデータが見える越境漏洩を、RLSを設計するだけでなく『分離が壊れていないこと』を検証で証明する方法を解説。pgTAP的な回帰テスト、所有権チェック、安全な動的プローブ(DAST)とSAST相関で確信度を上げる実践を、脆弱→検証の実コードで示します。

- 公開日: 2026-06-28
- 著者: 友田 陽大
- タグ: Supabase, RLS, B2B SaaS, セキュリティ, アーキテクチャ設計
- URL: https://tomodahinata.com/blog/supabase-multi-tenant-cross-tenant-leak-verification-guide
- カテゴリ: アプリ層セキュリティ
- 総合ガイド: https://tomodahinata.com/blog/nextjs-supabase-application-security-guide

## 要点

- テナント越境（他テナントのデータが見える）は、B2B SaaSで最も高くつく漏洩の一つ。RLSを『設計した』ことと、分離が『実際に効いている』ことはまったく別の事実
- 越境は主に4経路で起きる——RLSの抜け、service_role経路の所有権チェック漏れ、JOIN/ビュー/RPCの回り込み、IDを指定するAPI（IDOR/BOLA）
- 証明の中心は回帰テスト——テナントAのトークンでテナントBの行を叩き、必ず0件/拒否になることをpgTAPと統合テストでCIに固定する
- 静的解析（SAST）の『疑い』は、自分が所有する環境への安全な動的プローブ（DAST）で実行時の『確証』へ格上げできる。弱RLS × 非管理クライアントは確定した露出として相関する
- ただし正直に言えば——検証も監査も『完全に安全』は証明しない。よくある穴を潰して確信度を上げるだけで、残るリスクは必ずある。だから設計・検証・人のレビューを重ねる

---

最初に結論を述べます。**マルチテナントSaaSの「テナント分離」は、`RLSを設計した`時点では一切守られていません。守りになるのは、`別テナントになりすまして叩いても他テナントのデータが1件も返らない`ことを検証で示せたときです。** 設計は意図の宣言であり、検証はその意図が実装で壊れていないことの証拠です。この2つは、別々の仕事です。

そしてテナント越境（cross-tenant leak）は、B2B SaaSにとって**最も高くつく漏洩の一つ**です。1人のユーザーの情報が漏れるのではなく、「顧客企業Aの全データが、競合になりうる顧客企業Bに見える」という、契約・信頼・法務のすべてを同時に壊す事故になります。だからこそ、「たぶん効いている」ではなく「効いていることを示せる」状態まで持っていく価値があります。

本記事は、Supabase（PostgreSQL + RLS）でマルチテナントを組んだ前提で、**分離が壊れていないことを"検証で証明する"具体的な手段**——回帰テスト、所有権チェック、安全な動的プローブ、静的解析との相関——を実コードで示します。RLSをどう設計するかは[Supabaseのマルチテナンシー設計パターン](/blog/supabase-rls-production-multi-tenancy-patterns)や[マルチテナントSaaSのデータ分離・認可設計](/blog/multi-tenant-saas-data-isolation-authorization-design-guide)に譲り、ここでは一貫して「**書いた分離が、本当に効いているか**」だけを扱います。なお、Supabase × Next.js のセキュリティ全体像は[総合ガイド](/blog/nextjs-supabase-application-security-guide)にまとめています。

---

## 1. 結論：テナント分離は「設計した」では守れない

「全テーブルにRLSを張りました」は、安心の根拠になりません。理由は単純で、**RLSは"書いたとおり"にしか効かず、しかもRLSが効かない経路がいくつも併存する**からです。ポリシーの条件式に1文字の取りこぼしがあっても、`service_role` を使う経路が1本あっても、`security_invoker` を付け忘れたビューが1枚あっても、そこからテナントの壁は崩れます。

ここで重要なのは、**「分離が効いていること」は、肯定的な観測（=越境を試したが失敗した）でしか確認できない**という性質です。コードを眺めて「正しそう」と思うのは検証ではありません。検証とは、

> 「テナントBのアクセストークンで、テナントAの行を指して問い合わせたとき、結果が**必ず空（0件）**であること」を、機械的に・繰り返し・退行なく示すこと

です。これを満たして初めて、「設計した分離が壊れていない」と言えます。逆に言えば、この観測を一度も取っていないなら、分離は「祈り」であって「保証」ではありません。

ただし、ここで誠実に線を引いておきます。**検証は確信度を上げますが、バグの不在を証明はしません。** テストした境界は守られていると示せても、テストしていない経路・将来追加される経路までは保証できません。だから本記事の手法は「やれば完全に安全」ではなく、「**最も起きやすい越境を機械的に潰し、確信度を継続的に高く保つ**」ための実践です。この前提は最後まで一貫します。

---

## 2. テナント越境はどう起きるか——4つの典型経路

越境の入口は無数にあるように見えて、現場で繰り返し見るのは次の4経路に収束します。検証の設計は「この4経路を全部叩いたか」を基準にすると、抜けが減ります。

| 経路 | 何が起きるか | RLSとの関係 |
|---|---|---|
| **A. RLSの抜け** | RLS未有効化・条件式の取りこぼしで他テナント行が見える | RLSが「無い／弱い」 |
| **B. service_role 経路** | 管理クライアントで所有権を確認せずIDを受け取る | RLSを「飛び越える」 |
| **C. 回り込み** | ビュー/RPC/関数がRLSを迂回して下層テーブルを読む | RLSが「適用されない」 |
| **D. IDを指定するAPI** | テナントID/オブジェクトIDを自己申告のまま信じる（IDOR/BOLA） | RLSの「外側」 |

### 経路A：RLSの抜け——そもそも分離が「無い」

最も基本的な失敗は、RLSを有効化していない、あるいは条件式がテナントを縛れていないケースです。PostgreSQLのRLSはテーブル単位で有効化し、ポリシーで許可を足す設計で、**有効化しただけでポリシーが無ければデフォルト全拒否（fail-secure）**になります（[PostgreSQL Row Security Policies](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)）。問題は「有効化を忘れる」「条件が緩い」の2つです。

```sql
-- マルチテナントの最小スキーマ
create table tenants (
  id   uuid primary key default gen_random_uuid(),
  name text not null
);

-- ユーザーがどのテナントに属するか（所属＝認可の真実の源）
create table memberships (
  user_id   uuid not null references auth.users,
  tenant_id uuid not null references tenants,
  role      text not null default 'member',
  primary key (user_id, tenant_id)
);

create table invoices (
  id         uuid primary key default gen_random_uuid(),
  tenant_id  uuid not null references tenants,
  amount     integer not null,
  created_at timestamptz not null default now()
);

-- 危険：invoices に RLS を張り忘れると、anon キーで全テナントの請求書が読める
--   curl "https://<project>.supabase.co/rest/v1/invoices?select=*" -H "apikey: <anon-key>"
```

このクラスの設定ミスが「未認証で全テーブル読み書き可能」という最悪の形で現実化したのが、2025年に登録された [CVE-2025-48757](https://nvd.nist.gov/vuln/detail/CVE-2025-48757) です（不十分なRLSによりリモートの未認証攻撃者が任意テーブルを読み書き、CWE-863、CVSS 9.3 CRITICAL）。RLS設定ミスそのものの発見手法は[RLS設定ミスの検出・監査ガイド](/blog/supabase-rls-misconfiguration-detection-audit-guide)に詳しく書いています。正しい分離は、テナント所属を真実の源にしてこう張ります。

```sql
alter table memberships enable row level security;
alter table invoices    enable row level security;

-- ユーザーは自分の所属だけ読める
create policy "read own memberships"
on memberships for select
to authenticated
using ( user_id = (select auth.uid()) );

-- 請求書は「自分が所属するテナントの行」だけ読める
create policy "read invoices of my tenants"
on invoices for select
to authenticated
using (
  tenant_id in (
    select tenant_id from memberships
    where user_id = (select auth.uid())
  )
);
```

`auth.uid()` を `(select auth.uid())` で包むのはSupabase公式推奨で、行ごとの再評価を避けて大規模テーブルでの性能を保つためです（[Supabase: Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security)）。ここまでが「設計」。これが効いているかは、第4節以降で「検証」します。

### 経路B：service_role 経路で所有権チェックを忘れる

Supabaseの `service_role` キーは PostgreSQL の `BYPASSRLS` を持ち、**RLSを完全に飛び越えます**。公式も「service_role はRLSをバイパスする。サーバー側でのみ使うこと」と明記しています（[Supabase: Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security)）。第A経路のRLSを完璧に張っても、`service_role` を使うルートが1本あれば、そこだけRLSが無効化されます。

```ts
// app/api/invoices/route.ts — 脆弱：service_role でテナントIDを"自己申告"のまま信じる
import { createClient } from "@supabase/supabase-js";

const supabaseAdmin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!, // ← RLS をバイパスする管理クライアント
);

export async function POST(req: Request) {
  const { tenantId } = await req.json(); // ← クライアントが自由に指定できる値

  // このユーザーが tenantId に所属しているか一切確認していない＝テナント越境
  const { data } = await supabaseAdmin
    .from("invoices")
    .select("*")
    .eq("tenant_id", tenantId);

  return Response.json(data); // 他テナントの請求書がそのまま返る
}
```

`service_role` キーの所在と漏洩リスクそのものは[anonキー/service_roleキー露出ガイド](/blog/supabase-anon-key-service-role-key-exposure-guide)で扱っています。ここでの修正の原則は2つだけです——**(1) 可能なら service_role を使わず、ユーザーのトークンで動く anon クライアントでRLSに認可を任せる。(2) どうしても service_role が必要なら、所属（membership）をサーバーで必ず確認してからクエリする。**

```ts
// 修正：service_role を使うなら、テナント所属をサーバーで必ず検証する
export async function POST(req: Request) {
  const { tenantId } = await req.json();

  const user = await getAuthenticatedUser(req); // クライアントの自己申告を信じない
  if (!user) return Response.json({ error: "unauthorized" }, { status: 401 });

  // 所属を確認：このユーザーが tenantId のメンバーでなければ越境として弾く
  const { data: membership } = await supabaseAdmin
    .from("memberships")
    .select("tenant_id")
    .eq("user_id", user.id)
    .eq("tenant_id", tenantId)
    .maybeSingle();
  if (!membership) return Response.json({ error: "forbidden" }, { status: 403 });

  const { data } = await supabaseAdmin
    .from("invoices")
    .select("*")
    .eq("tenant_id", tenantId);
  return Response.json(data);
}
```

### 経路C：JOIN・ビュー・RPC 経由の回り込み

RLSはテーブルに対して効きますが、**それを"迂回"する経路がある**ことを見落としがちです。代表は2つ。

ひとつは**ビュー**。PostgreSQLのビューは既定でビュー所有者の権限で実行され、問い合わせユーザーのRLSが適用されません。Postgres 15以降は `security_invoker = on` を付けると問い合わせユーザーの権限（=RLS）が適用されます（[PostgreSQL Row Security Policies](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)）。

```sql
-- 危険：security_invoker を付けないビューは、所有者権限で下層を読む＝RLS回避
create view invoice_totals as
  select tenant_id, sum(amount) as total
  from invoices
  group by tenant_id;
-- このビューを authenticated に grant すると、全テナントの集計が誰にでも見える

-- 修正：問い合わせユーザーの権限（＝RLS）を適用させる
create view invoice_totals
  with (security_invoker = on)
  as
  select tenant_id, sum(amount) as total
  from invoices
  group by tenant_id;
```

もうひとつは **`SECURITY DEFINER` 関数 / RPC**。定義者権限で実行されるため、関数の中で所有権を確認しないと、引数で渡された任意の `tenant_id` に対してRLSを飛び越えて読み書きできてしまいます。

```sql
-- 危険：SECURITY DEFINER が任意 tenant_id を所有権チェックなしで受ける
create function get_tenant_invoices(p_tenant_id uuid)
returns setof invoices
language sql
security definer            -- 定義者権限で実行＝RLSを飛び越える
set search_path = ''        -- search_path 固定は必須（権限昇格対策）
as $$
  select * from public.invoices where tenant_id = p_tenant_id
$$;

-- 修正案1：問い合わせユーザーのRLSを効かせる（多くの場合これで十分）
create or replace function get_tenant_invoices(p_tenant_id uuid)
returns setof invoices
language sql
stable
security invoker
set search_path = ''
as $$
  select * from public.invoices where tenant_id = p_tenant_id
$$;

-- 修正案2：どうしても definer が必要なら、関数内で所属を必ず確認する
--   where exists (select 1 from public.memberships
--                 where user_id = auth.uid() and tenant_id = p_tenant_id)
```

回り込み経路は「RLSを張ったから安全」という思考停止の死角に正確に入るため、検証では**ビューとRPCを"テナント越え"の対象に必ず含める**ことが重要です。

### 経路D：IDを指定するAPI（IDOR / BOLA）

最後は、テナントID・オブジェクトIDを**クライアントの自己申告のまま信じる**経路です。これはOWASP API Security Top 10で2019年以来ずっと第1位の **API1:2023 Broken Object Level Authorization（BOLA）** そのものです（[OWASP API1:2023 BOLA](https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/)、[OWASP API Security Top 10](https://owasp.org/API-Security/editions/2023/en/0x11-t10/)）。

```text
POST /api/invoices  { "tenantId": "AAAA..." }  ← 自分のテナント（正規）
POST /api/invoices  { "tenantId": "BBBB..." }  ← IDを差し替えるだけ。これが通れば越境
```

経路Bと地続きですが、強調したいのは**「画面に出していないIDだから安全」は成立しない**こと。Server Actions（`"use server"`）も実質POSTエンドポイントで、引数のIDはHTTPで任意に差し替えられます。SupabaseでのIDORの生まれ方と修正は[IDOR/認可欠陥の検出ガイド](/blog/nextjs-supabase-idor-broken-authorization-rls-detection-guide)に詳述しています。本記事では、この4経路すべてを「**検証で塞げているか**」に話を進めます。

---

## 3. 「設計」と「検証」は、まったく別の仕事

ここを混同すると、いつまでも分離は証明できません。2つの仕事を明確に分けます。

| | 設計（Design） | 検証（Verification） |
|---|---|---|
| やること | 正しいRLS・所有権チェックを書く | 別テナントになりすまして叩き、0件/拒否を確認する |
| 成果物 | ポリシー・コード | テスト・プローブ結果・CIログ |
| 答える問い | 「どう分離するつもりか」 | 「分離は本当に壊れていないか」 |
| 失敗の見え方 | レビューで気づける（こともある） | **実行して初めて分かる** |

設計は意図の表明で、レビューで質を上げられます。しかし**意図が実装で壊れていないかは、動かして観測するしかない**。条件式の `=` が `<>` になっていても、`tenant_id` を縛る `where` が消えていても、コードは正常に動き、エラーも出ません。越境は「壊れていても静かに動く」種類の欠陥だからです。

だから検証の核は、肯定的観測です。具体的には次の不変条件（invariant）を、すべての主要テーブル・経路について確認します。

> **不変条件：テナントBの主体（JWT / アクセストークン）でテナントAの行を要求したとき、結果は必ず空であり、書き込みは必ず拒否される。**

この不変条件を「一度確認した」では足りません。スキーマもポリシーもコードも変わり続けるので、**退行（regression）しないことを継続的に**示す必要があります。次節の回帰テストは、まさにこの不変条件をCIに固定するための手段です。

---

## 4. 検証手法①：回帰テスト——越境を"自動で"再現して拒否を確認する

回帰テストは、テナント越境検証の中心です。狙いは「**人がいなくても、コミットのたびに越境が再現されないことを示す**」こと。2つの層で書きます——DB内で完結する pgTAP と、API越しに叩く統合テスト。両者は守備範囲が違うので、両方やる価値があります。pgTAPでのRLS回帰テストの土台は[pgTAPによるRLSポリシー回帰テストガイド](/blog/supabase-rls-testing-pgtap-policy-regression-guide)も参照してください。

### 4-1. pgTAP でRLSポリシーをDB内で直接テストする

pgTAPはPostgreSQL内で動くテストフレームワークで、**RLSポリシーの真偽をDBに最も近い層で**検証できます。Supabaseでは、認証コンテキストを `request.jwt.claims` と `role` の設定で偽装し、そのユーザーになりきって問い合わせます。`auth.uid()` はこのクレームの `sub` を読みます。

```sql
-- supabase/tests/tenant_isolation.test.sql
begin;
select plan(5);

-- 1) 固定IDでフィクスチャを用意（テナントA・B と、それぞれの所属ユーザー）
insert into tenants (id, name) values
  ('11111111-1111-1111-1111-111111111111', 'Tenant A'),
  ('22222222-2222-2222-2222-222222222222', 'Tenant B');

insert into auth.users (id, email) values
  ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'a@example.com'),
  ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'b@example.com');

insert into memberships (user_id, tenant_id) values
  ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '11111111-1111-1111-1111-111111111111'),
  ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '22222222-2222-2222-2222-222222222222');

insert into invoices (id, tenant_id, amount) values
  ('a0000000-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', 1000), -- Aの請求書
  ('b0000000-0000-0000-0000-000000000001', '22222222-2222-2222-2222-222222222222', 2000); -- Bの請求書

-- 2) テナントBのユーザーになりきる
set local role authenticated;
set local request.jwt.claims to
  '{"sub":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","role":"authenticated"}';

-- 3) 不変条件①：Bのユーザーから、Aのテナントの行は1件も見えない
select is(
  (select count(*)::int from invoices
   where tenant_id = '11111111-1111-1111-1111-111111111111'),
  0,
  'テナントBのユーザーはテナントAの請求書を読めない'
);

-- 4) 不変条件②：Bのユーザーには自分のテナントの行だけが見える
select is(
  (select count(*)::int from invoices),
  1,
  'テナントBのユーザーに見えるのは自テナントの1件だけ'
);

-- 5) 不変条件③：Aの行へのIDピンポイント参照も空
select is_empty(
  $$ select * from invoices where id = 'a0000000-0000-0000-0000-000000000001' $$,
  'IDを直接指定してもテナントAの行は取得できない'
);

-- 6) 不変条件④：Aのテナントへの書き込みはRLSで拒否される
select throws_ok(
  $$ insert into invoices (tenant_id, amount)
     values ('11111111-1111-1111-1111-111111111111', 9999) $$,
  '42501', -- insufficient_privilege（RLS の WITH CHECK 違反）
  null,
  'テナントBのユーザーはテナントAへ請求書を作成できない'
);

-- 7) 不変条件⑤：回り込みビューも越境しない（security_invoker=on の確認）
select is(
  (select count(*)::int from invoice_totals
   where tenant_id = '11111111-1111-1111-1111-111111111111'),
  0,
  'ビュー経由でもテナントAの集計は見えない'
);

select * from finish();
rollback;
```

ポイントを3つ。**(1)** `begin … rollback` で囲み、テストは副作用を残しません。**(2)** 書き込み拒否は `throws_ok` でエラーコード `42501` を期待し、「静かに0件」ではなく「明示的に拒否」を確認します。**(3)** ビュー（経路C）まで対象に含めることで、回り込みの退行も捕まえます。`request.jwt.claims` を手で組むのが煩雑なら、`supabase_test_helpers` の `tests.authenticate_as()` などのヘルパーで同じことを簡潔に書けます。

CIでは Supabase CLI から実行します。

```bash
# ローカル/CIでRLS回帰テストを実行（DBをリセットしてからテスト）
supabase db reset
supabase test db
```

### 4-2. 統合テスト：実トークンでAPI越しに叩く

pgTAPはDB内の真実を確認しますが、**アプリのコード（経路B・D）はテストしません**。`service_role` で所有権チェックを忘れたRoute Handlerは、DBのRLSが完璧でも越境します。だから「実際に発行されたアクセストークンで、APIを越境させにいく」統合テストを別に持ちます。

```ts
// tests/tenant-isolation.test.ts — 実トークンでAPI越しにテナント越えを試す
import { createClient } from "@supabase/supabase-js";
import { beforeAll, expect, test } from "vitest";

const URL = process.env.SUPABASE_URL!;
const ANON = process.env.SUPABASE_ANON_KEY!;
const TENANT_A = "11111111-1111-1111-1111-111111111111";

let tokenB: string; // テナントBのユーザーのアクセストークン

beforeAll(async () => {
  const sb = createClient(URL, ANON);
  const { data, error } = await sb.auth.signInWithPassword({
    email: "b@example.com",
    password: process.env.TEST_USER_B_PASSWORD!, // テスト専用アカウントの資格情報
  });
  if (error) throw error;
  tokenB = data.session!.access_token;
});

// Bのトークンで動くクライアント＝RLSが効く。越境していなければ必ず空。
function asTenantB() {
  return createClient(URL, ANON, {
    global: { headers: { Authorization: `Bearer ${tokenB}` } },
  });
}

test("PostgREST: BのトークンでAの請求書は0件", async () => {
  const { data, error } = await asTenantB()
    .from("invoices")
    .select("*")
    .eq("tenant_id", TENANT_A);
  expect(error).toBeNull();
  expect(data).toEqual([]); // 越境していなければ空
});

test("REST API: Bのトークンで tenantId=A を要求しても漏れない", async () => {
  const res = await fetch("http://localhost:3000/api/invoices", {
    method: "POST",
    headers: {
      "content-type": "application/json",
      authorization: `Bearer ${tokenB}`,
    },
    body: JSON.stringify({ tenantId: TENANT_A }), // 他テナントIDを自己申告（経路D）
  });
  // 403 で弾く実装が理想。最低でも本文に他テナントの行が含まれないこと。
  expect([401, 403, 404]).toContain(res.status);
  const body = await res.text();
  expect(body).not.toContain(TENANT_A);
});
```

この2層を合わせると、**「DBのRLS」と「アプリの所有権チェック」の両方**に対して不変条件を張れます。どちらか一方だけでは、第2節の4経路に必ず穴が残ります。

> テスト用アカウントの資格情報は環境変数（Secrets）で渡し、コードにもログにも残さないこと。テストとはいえ実トークンを扱うので、ここでの漏洩は本番の鍵漏洩と同じ重みです。

---

## 5. 検証手法②：安全な動的プローブ（DAST）で「疑い」を「確証」に上げる

回帰テストは「自分で書いた不変条件」を守ります。一方で、**書き忘れた経路**——新しく増えたエンドポイント、テストを書いていないRPC、見落としていたビュー——は、回帰テストの網に最初から入りません。ここを補うのが、動的プローブ（DAST）です。

考え方は、OWASPの[Web Security Testing Guide](https://owasp.org/www-project-web-security-testing-guide/)が示す「認可テスト」の実務そのものです。**2つのアイデンティティ（テナントA・B）を用意し、片方の主体でもう片方のリソースを指して叩き、応答を観測する**。これを、エンドポイントを横断して機械的に回します。

ただし、ここには厳格な倫理と安全のルールがあります。

- **対象は自分が所有・管理する環境だけ**（本番ではなくステージング推奨）。他者の環境へのプローブは攻撃です。
- **非破壊**：読み取り中心。書き込みプローブはロールバック可能な範囲・専用テナントに限定。
- **スコープ固定**：対象ホスト・パスを明示的に絞り、想定外への波及を防ぐ。

静的解析（SAST）は「ここは所有権で絞られていない疑いがある」と**疑い**を出すところまで。動的プローブは、その疑いを**実際に他テナントになって叩き、漏れたか否かの実行時の事実**に変えます。私が公開しているOSSでは、この相関を1コマンドで回せます。

```bash
# 自分が所有する環境に対してのみ、非破壊・スコープ固定でプローブする
# --correlate で静的解析の「疑い」と実行時の「確証」を突き合わせる
npx @aegiskit/cli probe https://staging.example.com --correlate
```

誇張せずに言えば、これは魔法ではありません。プローブできるのは「叩けて、観測できる」経路だけで、複雑な業務フローの奥にある越境は人手のテスト設計が要ります。**DASTは確証の格上げ装置であって、網羅性の保証ではない**——この線引きは、第8節の監査の話と完全に地続きです。

---

## 6. 「弱いRLS」×「非管理クライアント」＝確定した露出として相関する

検証の精度を上げる鍵は、**別々の証拠を突き合わせて"確定"を作る**ことです。単独では「疑い」止まりの情報も、組み合わせると「確定した露出」に格上げできます。最も効く相関は次の組です。

| 証拠①（静的：SQL側） | 証拠②（静的：コード側） | 相関結論 |
|---|---|---|
| あるテーブルのRLSが弱い/無い | そのテーブルを **anon/非管理クライアント**から直接クエリしている | **確定した露出**（公開APIから素通しで読める） |
| RLSは妥当 | だが **service_role** で所有権チェックなしにクエリしている | **確定した越境経路**（RLSが効かない） |
| ビューに `security_invoker` 無し | そのビューを authenticated に grant | **回り込みによる露出** |

この相関の価値は、**ノイズを増やさずに優先度を付けられる**ことにあります。「RLSが弱い」だけでは、内部専用テーブルかもしれず断定できない。「非管理クライアントから読んでいる」だけでも、RLSが守っているかもしれない。しかし**両方が同時に成立する箇所**は、理屈の上で必ず漏れます。ここに第5節の動的プローブが「実際に漏れた」を重ねれば、**静的な疑い → 構造的な確定 → 実行時の確証**という三重の証拠が揃い、最優先で直すべき欠陥として迷いなく扱えます。

逆に、相関が取れない単独の警告は後回しにできます。検証で疲弊する最大の原因は「全部が等しく怖い」状態なので、**相関で序列を付ける**ことは、検証を継続可能にする実務上の生命線です。

---

## 7. テナント越境 検証チェックリスト（B2B向け）

外注・内製・AI生成のいずれであっても、本番投入の前に「分離が壊れていないと示せたか」をこの観点で確認してください。発注者の立場でも、開発者にこの問いを投げれば、検証が設計に組み込まれているかが分かります。

| 観点 | 確認すること（=証拠の有無） | 危険信号 |
|---|---|---|
| **RLS有効化** | テナント由来の全テーブルでRLSが有効か | `enable row level security` の無いテーブルがある |
| **テナント条件** | ポリシーが所属（memberships）を真実の源に縛っているか | `using (true)` や、ユーザー単位で止まりテナントを縛れていない |
| **service_role経路** | service_role を使うAPIで所属を必ず検証しているか | IDを受けて所有権チェックなしでクエリしている |
| **回り込み** | ビューに `security_invoker=on`、RPCに所有権ガードがあるか | ビュー/関数を「RLS対象外」と認識していない |
| **越境回帰テスト** | 「Bのトークンでも空」をpgTAP/統合テストで固定したか | 「手元で動いた」以外の証拠が無い |
| **書き込み拒否** | 他テナントへのinsert/updateが明示的に拒否されるか | 読み取りしか検証していない |
| **動的プローブ** | 自分の環境で2アイデンティティの越境プローブを回したか | 実行時に他テナントを叩いた記録が一度も無い |
| **退行防止** | これらがCIで毎コミット走るか | テストはあるが手動でしか実行されない |

発注者の視点で最も効く2問は、**「テナントBのトークンでテナントAのデータを叩いたらどうなりますか？」「その結果を示すテスト／ログはありますか？」**です。良い開発者は、設計の説明ではなく**検証の証拠**で即答します。私自身、[B2BサブスクリプションSaaSの開発](/case-studies/lumber-industry-dx)で、テナント分離は「設計図」ではなく「越境を叩いて落ちないこと」を継続的に示すことで初めて運用に乗る、という前提で組んできました。

---

## 8. 自分での検証と、第三者監査——どこまでで「証明」になるのか

ここまでの手法は、すべて**自分（チーム）で回せる**ものです。回帰テストと動的プローブをCIに組み込めば、確信度は大きく上がります。まずはここを固めるのが費用対効果で最良です。

その上で、第三者監査の価値も正直に位置づけます。**自分の検証には「自分が想定した経路しか叩かない」という構造的な盲点**があります。設計者が「ここは越境しないはず」と思い込んだ経路は、テストにもプローブにも最初から含まれません。第三者は、その思い込みの外側——想定していないRPCの組み合わせ、複数テナントにまたがる招待フロー、共有リソースの端——を脅威モデリングから叩きにいきます。これがOWASP API1:2023 BOLA（[OWASP API1:2023 BOLA](https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/)）が「最頻出かつ最も見落とされる」と言われ続ける理由でもあります。

ただし、ここでも誠実に線を引きます。**第三者監査も「完全に安全」を証明はしません。** 監査が示せるのは「定めたスコープと期間で、用いた手法では、これだけの越境経路が見つからなかった/見つかった」という事実までです。スコープ外・手法外・将来の変更に対する保証ではありません。だから監査結果は「お墨付き」ではなく、**確信度を一段引き上げ、残るリスクを明示する**ものとして扱うのが正しい。監査がどこまでをカバーし何を保証しないか、いつ依頼すべきかは[セキュリティ監査の範囲ガイド](/blog/nextjs-supabase-security-audit-scope-when-needed-guide)に整理しています。

整理すると、テナント分離の「証明」は段階的です。

1. **設計**：RLS＋所有権チェックを書く（=意図の宣言）
2. **回帰テスト**：自分の不変条件をCIで退行なく守る（=確信度を上げる）
3. **動的プローブ**：疑いを実行時の確証に格上げする（=確信度をさらに上げる）
4. **第三者監査**：想定外の盲点を外から叩く（=確信度をもう一段上げ、残リスクを明示）

どの段階も「完全」には到達しませんが、**重ねるほど越境が起きる確率は確実に下がり、起きたときに気づける速さも上がります**。私は自作のセキュリティツール群（[Aegis / `npx @aegiskit/cli`](/aegis)）でこの2〜3段を自動化し、踏み込んだ確認が必要な場合は[第三者によるセキュリティ監査](/aegis/audit)として提供しています。AIで速く作ること自体は正しい。**速く作ったものの分離が壊れていないことを、証拠で示せる状態にする**——その仕組みづくりや既存アプリのテナント分離レビューが必要であれば、お気軽にご相談ください。

---

## よくある質問（FAQ）

**Q. 全テーブルにRLSを張れば、テナント越境は防げますか？**
A. 必要条件ですが、十分ではありません。第2節のとおり service_role 経路（B）、ビュー/RPCの回り込み（C）、ID自己申告のAPI（D）はRLSの外側・回避経路です。「張った」ことと「効いている」ことは別なので、回帰テストと動的プローブで「越境が再現しない」ことまで示して初めて守りになります。

**Q. pgTAPの回帰テストがあれば、統合テストは不要では？**
A. いいえ。pgTAPはDB内のRLSの真実を確認しますが、アプリのコード（service_role経路や所有権チェックの書き忘れ）は通りません。逆に統合テストはコードを通りますが、DB内部の細かなポリシー条件は網羅しづらい。守備範囲が違うので、両方を持つのが正解です。

**Q. 動的プローブ（DAST）は本番に対して実行していいですか？**
A. 原則ステージングなど自分が所有・管理し、データを壊しても問題ない環境で行ってください。本番に対しては、影響範囲・非破壊・スコープを厳密に管理した上で慎重に。他者の環境へのプローブは、許可なく行えば攻撃に該当します。

**Q. 検証をすべて通れば「安全宣言」してよいですか？**
A. してはいけません。検証が示すのは「テストした境界では越境が再現しなかった」という事実までで、テストしていない経路・将来の変更は保証しません。残るリスクを明示した上で「現時点で確認できた範囲」と述べるのが誠実で、結果的に信頼されます。

**Q. テナントIDを「JWTのカスタムクレームに入れる」設計と、「membershipsテーブルで引く」設計はどちらが良いですか？**
A. どちらも実務で使われます。クレームに入れると速い反面、所属変更の反映やクレーム改ざん耐性の設計が要ります。テーブルで引くと常に最新で監査しやすい反面、ポリシーにサブクエリが増えます。重要なのは選択そのものより、**選んだ設計で越境が起きないことを検証で示せること**です。設計の比較は[マルチテナント分離・認可設計ガイド](/blog/multi-tenant-saas-data-isolation-authorization-design-guide)を参照してください。

---

## まとめ：分離は「書いた」ではなく「壊れていないと示せた」で初めて守りになる

要点を整理します。

- テナント越境はB2B SaaSで最も高くつく漏洩の一つ。**RLSを「設計した」ことと、分離が「効いている」ことは別の事実**です。
- 越境は4経路で起きる——**RLSの抜け（A）／service_role経路の所有権漏れ（B）／ビュー・RPCの回り込み（C）／IDを指定するAPI＝IDOR・BOLA（D）**。検証はこの4経路を全部叩いたかで設計する。
- 証明の核は**不変条件**「テナントBの主体でテナントAの行を要求したら必ず空、書き込みは必ず拒否」を、**pgTAPと統合テストでCIに固定**すること。読み取りだけでなく書き込み拒否まで確認する。
- 静的解析の**疑い**は、自分が所有する環境への**安全な動的プローブ（DAST）で確証**に格上げできる。**弱RLS × 非管理クライアント**は確定した露出として相関する。
- そして正直に——**検証も第三者監査も「完全に安全」は証明しない。** 確信度を段階的に上げ、残るリスクを明示するための営みです。だから設計・テスト・プローブ・人のレビューを重ねます。

分離は、コードに「書いた」瞬間ではなく、「壊れていないと**示せた**」瞬間に、初めて守りになります。設計図ではなく、越境を叩いて落ちない証拠を——それが、テナント分離を「祈り」から「保証」に変える唯一の道です。

---

## 参考資料

- [OWASP API Security Top 10 — API1:2023 Broken Object Level Authorization (BOLA)](https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/)
- [OWASP API Security Top 10（2023年版・全体）](https://owasp.org/API-Security/editions/2023/en/0x11-t10/)
- [OWASP Web Security Testing Guide（認可テストの実務）](https://owasp.org/www-project-web-security-testing-guide/)
- [Supabase Docs — Row Level Security（service_roleはRLSをバイパスする）](https://supabase.com/docs/guides/database/postgres/row-level-security)
- [PostgreSQL — Row Security Policies（ビュー/SECURITY DEFINERとRLSの関係）](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)
- [NVD — CVE-2025-48757（不十分なRLSによる未認証アクセス、CWE-863、CVSS 9.3）](https://nvd.nist.gov/vuln/detail/CVE-2025-48757)
