# Supabase RLSの WITH CHECK 欠落で起きる『書き込みバイパス』 — USING との違いと、INSERT/UPDATE を正しく守る

> SupabaseのRLSで混同しやすいUSING（読み取りフィルタ）とWITH CHECK（書き込みフィルタ）の違いを整理し、WITH CHECKを欠いた書き込みポリシーが認証済みユーザーに他人のuser_idや他テナントのidを持つ行を作らせる『書き込みバイパス』を、INSERT/UPDATE/ALLの脆弱→修正SQLで解説します。

- 公開日: 2026-06-28
- 著者: 友田 陽大
- タグ: Supabase, RLS, PostgreSQL, セキュリティ
- URL: https://tomodahinata.com/blog/supabase-rls-with-check-using-write-bypass-guide
- カテゴリ: アプリ層セキュリティ
- 総合ガイド: https://tomodahinata.com/blog/nextjs-supabase-application-security-guide

## 要点

- USING は『どの既存行が見えるか』を決める読み取りフィルタ、WITH CHECK は『どの新しい行を書けるか』を決める書き込みフィルタ。SELECT/DELETE は USING のみ、INSERT は WITH CHECK のみ、UPDATE は両方が効く
- WITH CHECK を持たない（または with check (true) にした）書き込みポリシーは『書き込みバイパス』を開く——認証済みユーザーが INSERT で他テナントの id を差し込み、UPDATE で行の user_id を他人に書き換えられる
- UPDATE/ALL では WITH CHECK 省略時に USING が新しい行にも流用される（フォールバック）。だが INSERT にフォールバックは無く、with check (true) を書いた瞬間に保護は消え、USING がピン留めしない列（role・tenant_id 等）の昇格は防げない
- AI生成・短納期で量産される。INSERT時の『new row violates row-level security policy』エラーを黙らせる最短手が with check (true) で、それがそのまま穴になる。CVE-2025-48757 は不十分なRLSが未認証アクセスを許した実例
- 正直に言うと、migrations の静的検証や npx @aegiskit/cli scan は『WITH CHECK の欠落・true・USINGとの不一致』という“形”を検出できるが、その述語が正しい所有権・テナント条件かは人間の設計判断であり、ツールは認可の正しさを証明しない

---

最初に結論を述べます。**Supabase（PostgreSQL）の行レベルセキュリティ（RLS）でいちばん事故が多いのは、`USING` と `WITH CHECK` の混同です。`USING` は「どの“既存行”が見えるか」を決める読み取りフィルタ、`WITH CHECK` は「どの“新しい行”を書いてよいか」を決める書き込みフィルタで、両者はまったく別の関門です。** 読み取り側（`USING`）だけを丁寧に書き、書き込み側（`WITH CHECK`）を欠いた——あるいは `with check (true)` で黙らせた——ポリシーは、**認証済みの正規ユーザーが「他人の `user_id` を持つ行」や「他テナントの `id` を差し込んだ行」を作れてしまう『書き込みバイパス』**を開きます。

これは直感に反するねじれた事故です。「自分の行しか*読めない*」テーブルに、「他人の行を*書ける*」という穴が同居する。読み取りのテストは通り、デモでも顕在化せず、本番で初めて——あるいは攻撃者にだけ——露見します。本記事は、`USING` と `WITH CHECK` の違いをコマンド別に正確に押さえ、INSERT/UPDATE/ALL の脆弱SQLを実際の攻撃とともに分解し、修正SQLと検出・検証の手順まで、PostgreSQL/Supabase の一次情報に基づいて解説します。これは [Next.js × Supabase アプリケーションセキュリティ完全ガイド](/blog/nextjs-supabase-application-security-guide) で「垂直リスク（設計でしか守れない認可）」と呼んだ層を、**書き込み側に絞って**深掘りするものです。

---

## 1. 結論を分解する：USING と WITH CHECK は別の関門

RLS のポリシーには、条件式を書く場所が2つあります。`USING` と `WITH CHECK` です。役割は明確に分かれています（[PostgreSQL: CREATE POLICY](https://www.postgresql.org/docs/current/sql-createpolicy.html)）。

- **`USING`（既存行の検査）** — すでにテーブルに存在する行に対して評価され、`true` になった行だけが「見える／操作対象にできる」。これは**読み取り側のフィルタ**です。
- **`WITH CHECK`（新しい行の検査）** — `INSERT` や `UPDATE` で生まれる**新しい行の内容**に対して評価され、`false`（または null）になればエラーで弾かれる。これは**書き込み側のフィルタ**です。公式は明記しています——「`check_expression` は行の*元の内容*ではなく*提案された新しい内容*に対して評価される」。

つまり `USING` は「どの行を見せるか」、`WITH CHECK` は「どんな行を書かせるか」。**読み取りを縛っても書き込みは縛られません。** この一点が、本記事のすべてです。

どのコマンドでどちらが効くかを表にします。これは [PostgreSQL: CREATE POLICY](https://www.postgresql.org/docs/current/sql-createpolicy.html) の「Policies Applied by Command Type」に対応します。

| コマンド | USING（既存行に対する検査） | WITH CHECK（新しい行に対する検査） |
|---|---|---|
| SELECT | 効く（見える行を絞る） | 指定できない |
| INSERT | 指定できない（既存行が無い） | 効く（作る行を検査する） |
| UPDATE | 効く（更新できる既存行を絞る） | 効く（更新後の行を検査する） |
| DELETE | 効く（削除できる既存行を絞る） | 指定できない |
| ALL | 効く | 効く |

ここから読み取れる重要な事実は3つです。

1. **`SELECT` と `DELETE` は `USING` だけ**。読む・消すは「既存行」の話なので、新しい行の検査（`WITH CHECK`）は出番がありません。
2. **`INSERT` は `WITH CHECK` だけ**。既存行が無いので `USING` は**指定すらできません**（公式：「`INSERT` ポリシーは `USING` 式を持てない」）。つまり**挿入の安全性は `WITH CHECK` ただ一つに懸かっている**。
3. **`UPDATE` は両方効く**。「どの既存行を更新できるか」を `USING` で、「更新後の行に何を許すか」を `WITH CHECK` で、二段で縛ります。

RLS そのものの位置づけは [Supabase: Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security) と [PostgreSQL: Row Security Policies](https://www.postgresql.org/docs/current/ddl-rowsecurity.html) が一次情報です。RLS を「有効化した」ことと「読み書きの両方が正しく効いている」ことは別物——本記事はその後半、**書き込み側の検証**を扱います。

---

## 2. UPDATE/ALL のフォールバックという“落とし穴つきの安全網”

ここで、多くの解説が省く重要な挙動を正確に押さえます。**`UPDATE` と `ALL` のポリシーで `WITH CHECK` を省略すると、PostgreSQL は `USING` 式を `WITH CHECK` にも流用します。** 公式の言葉です——「`USING` と `WITH CHECK` の両方を持てるポリシー（`ALL` と `UPDATE`）で `WITH CHECK` が定義されていない場合、`USING` 式が、見える行を決める用途（通常の `USING`）と、追加を許す新しい行を決める用途（`WITH CHECK`）の**両方**に使われる」（[PostgreSQL: CREATE POLICY](https://www.postgresql.org/docs/current/sql-createpolicy.html)）。

[PostgreSQL: Row Security Policies](https://www.postgresql.org/docs/current/ddl-rowsecurity.html) のマネージャー例も同じ趣旨です——「このポリシーは `USING` と同一の `WITH CHECK` 句を暗黙に提供するので、制約は*選択される行*にも*変更される行*にも適用される（=他のマネージャーの行を `INSERT`/`UPDATE` で作れない）」。

これは一見ありがたい安全網ですが、**3つの理由で頼ってはいけません。**

- **`INSERT` にはフォールバックが無い。** `INSERT` ポリシーは `USING` を持てないので、流用元がそもそも存在しません。挿入は `WITH CHECK` を**自分で書く**しかない。
- **`with check (true)` を書いた瞬間に消える。** 「省略」したときだけ流用されるので、明示的に `with check (true)` と書くとフォールバックは働かず、新しい行は**無検査**になります（後述する最頻出の事故）。
- **`USING` がピン留めしていない列は守られない。** フォールバックは `USING` 式をそのまま再利用するだけ。`using (user_id = auth.uid())` は新しい行の `user_id` は縛りますが、`role` や `tenant_id` のような**別の列の書き換え**は素通りさせます（第5節で詳述）。

整理すると、「読み取りは守れているのに書き込みが無防備」になるパターンは次のとおりです。

| ポリシーの書き方 | 読み取り(SELECT) | 書き込み(INSERT/UPDATE) | 結果 |
|---|---|---|---|
| FOR SELECT USING(所有権) のみ | 守られる | 書き込みポリシー無し＝既定で全拒否 | 書けない（fail-secure。バグではない） |
| FOR INSERT WITH CHECK(true) | 別途SELECT次第 | 新しい行を検査しない | 他テナントのidを差し込める＝バイパス |
| FOR UPDATE USING(所有権) WITH CHECK(true) | 別途SELECT次第 | 更新後の行を検査しない | 行のuser_idを書き換えられる＝バイパス |
| FOR ALL USING(所有権)（WITH CHECK省略） | 守られる | フォールバックでUSINGを流用 | 所有権列は守られる（ただし他列の昇格は別問題） |

注目してほしいのは1行目です。**「書き込みポリシーが無い」のはバイパスではありません。** RLS を有効化してポリシーが1つも無ければ、既定は「全拒否（fail-secure）」。書き込みは静かに弾かれます。問題は、その「弾かれる」状態を**雑に解除したとき**に起きます——次節へ。

---

## 3. なぜ書き込み側が無防備になるのか：AI量産と「エラーを黙らせる」誘惑

なぜこの穴がこれほど量産されるのか。理由は2つあります。

### 3-1. 「new row violates row-level security policy」を黙らせる最短手

RLS を有効化したテーブルに `INSERT` すると、書き込みポリシーが無い（または `WITH CHECK` を満たさない）場合に、Supabase/PostgREST はこのエラーを返します。

```text
new row violates row-level security policy for table "documents"
```

開発者（や生成AIエージェント）がこのエラーに直面したとき、最短で消す方法は **`with check (true)`** です。エラーは消え、画面は動き、デモは通ります。しかし第2節で見たとおり、`with check (true)` は**新しい行の検査を完全に無効化**する宣言にほかなりません。「動いた」と「安全」が最も鋭く乖離する瞬間です。正しい対処は、`true` ではなく**所有権・テナント帰属を表す述語**を書くことです（第4・5節）。

### 3-2. ハッピーパスの外側はデモで顕在化しない

生成AIは、プロンプトで指示された「やりたいこと（＝デモで動くこと）」を最短距離で実装します。「他人の `user_id` を差し込ませない」「他テナントの行を作らせない」は、明示的に要求されない限りハッピーパスの外側にあり、**自分のアカウントで触っている限り誰も踏みません**。読み取り側（`USING`）は画面に出るので意識されますが、書き込み側（`WITH CHECK`）は目に見えず、後回しにされます。

これは机上の懸念ではありません。2025年登録の **[CVE-2025-48757](https://nvd.nist.gov/vuln/detail/CVE-2025-48757)** は、AI生成プラットフォーム（Lovable、2025-04-15まで）の**不十分なRow-Level Securityポリシー**により、リモートの**未認証**攻撃者が生成サイトの任意のDBテーブルを**読み書き**できた、という事例です。分類は **CWE-863（Incorrect Authorization）**、CVSS基本値 **9.3 CRITICAL**。「読み書き」と明記されているとおり、RLS不備は読み取りだけでなく**書き込みの欠陥**でもあります。

そして、書き込みバイパスは「オブジェクト単位の認可」の失敗の一種です。OWASP はこの認可欠陥クラスを **[API1:2023 Broken Object Level Authorization（BOLA）](https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/)** として API リスクの**第1位**に置いています。読み取り側の現れ方（他人のデータが見える IDOR）は [IDOR/認可欠陥の検出ガイド](/blog/nextjs-supabase-idor-broken-authorization-rls-detection-guide) に、本記事はその**書き込み側**——他人の所有として行を作る／書き換える——を扱います。

---

## 4. 脆弱→修正①：INSERT で他テナントの id を差し込む

最初の攻撃は `INSERT` です。`INSERT` は `USING` を持てず、フォールバックも無いため、**`WITH CHECK` が唯一の関門**。ここが `true` だと、認証済みユーザーは任意の所有者・任意のテナントの行を作れます。

### 脆弱なポリシー

マルチテナントの `documents` テーブルを想定します。読み取りは自テナントだけに絞れていますが、挿入の検査がありません。

```sql
-- 脆弱：読み取りは自テナントに絞れているが、INSERT の検査が true で無防備
alter table documents enable row level security;

-- 読み取り：自分のテナントの行だけ見える（ここは正しい）
create policy "read own tenant documents"
on documents for select
to authenticated
using ( tenant_id = ((select auth.jwt()) -> 'app_metadata' ->> 'tenant_id')::uuid );

-- 書き込み：ここが穴。新しい行を一切検査していない
create policy "insert documents"
on documents for insert
to authenticated
with check ( true );
```

### 攻撃：他テナントの行を作る

`documents` テーブルは Supabase が PostgREST 経由で REST API として公開します。攻撃者は自分のセッション（`authenticated`）のまま、`tenant_id` に**他社のID**を入れて挿入するだけです。

```ts
// 攻撃：自分は authenticated だが、他テナントの tenant_id を持つ行を差し込む
const { error } = await supabase.from("documents").insert({
  title: "汚染ドキュメント",
  tenant_id: "00000000-0000-0000-0000-0000000000bb", // ← 自分のテナントではない
  body: "他テナントの空間に行を作る",
});
// WITH CHECK(true) は新しい行を検査しないため、これが成功する＝書き込みバイパス
```

挿入された行は他テナントの `tenant_id` を持つため、**被害テナントの画面・集計・通知に紛れ込みます**。改ざん・なりすまし投稿・課金データの汚染など、影響はスキーマ次第で深刻です。テナント境界がどこで破れるかの検証手順は [マルチテナントのクロステナント漏洩検証](/blog/supabase-multi-tenant-cross-tenant-leak-verification-guide) に切り出しています。

### 修正：新しい行の tenant_id を「呼び出し元のテナント」に縛る

`true` を、**サーバーだけが書ける `app_metadata` のテナントID**と一致するか検査する述語に置き換えます。`user_metadata`（ユーザー自身が書き換え可能）を根拠にすると、攻撃者が自分のJWTを書き換えて回避できるので使いません。

```sql
-- 修正：新しい行の tenant_id が、呼び出し元のテナントと一致することを強制する
drop policy "insert documents" on documents;

create policy "insert into own tenant"
on documents for insert
to authenticated
with check (
  tenant_id = ((select auth.jwt()) -> 'app_metadata' ->> 'tenant_id')::uuid
);
```

これで、他テナントの `tenant_id` を持つ行の挿入は `new row violates row-level security policy` で弾かれます。`auth.uid()` で個人所有を縛る場合も同型です（`with check ( user_id = (select auth.uid()) )`）。`(select ...)` で包むのは Supabase 推奨の最適化で、行ごとの関数再評価を避けて初期計画にキャッシュさせるためです。

---

## 5. 脆弱→修正②：UPDATE で user_id を他人に書き換える

次は `UPDATE` です。`UPDATE` は `USING`（更新できる既存行）と `WITH CHECK`（更新後の行）の両方が効きます。ここで `WITH CHECK (true)` を書くと、「自分の行しか選べない」のに「書き換えた結果は何でも通る」という、ねじれた穴が開きます。

### 脆弱なポリシー

```sql
-- 脆弱：USING で「自分の行だけ更新対象にできる」が、WITH CHECK(true) で“書ける値”は無制限
create policy "update own profile"
on profiles for update
to authenticated
using ( user_id = (select auth.uid()) )
with check ( true );
```

### 攻撃：行の乗っ取り／権限昇格

`USING` を満たす（＝自分が所有する）行を選び、その行の `user_id` を別人に書き換える、あるいは権限列を昇格させます。

```ts
// 攻撃A：自分の行を選び、その user_id を別人に書き換える（行の譲渡・乗っ取り）
await supabase
  .from("profiles")
  .update({ user_id: "11111111-1111-1111-1111-111111111111" })
  .eq("user_id", currentUserId);

// 攻撃B：所有はそのまま、権限列だけ昇格させる
await supabase
  .from("profiles")
  .update({ role: "admin" })
  .eq("user_id", currentUserId);
```

`WITH CHECK(true)` は更新後の行を検査しないので、両方とも通ります。

### 修正：更新後の行も「自分の所有」であることを強制する

```sql
-- 修正：USING と WITH CHECK の両方で所有権を縛る（user_id の書き換えを封じる）
create policy "update own profile"
on profiles for update
to authenticated
using      ( user_id = (select auth.uid()) )   -- どの既存行を更新できるか
with check ( user_id = (select auth.uid()) );  -- 更新後の行に許す値
```

### 正直な注意：フォールバックは“user_id だけ”を守る

ここで誠実に補足します。**もし `with check (true)` を書かず、`WITH CHECK` を完全に省略していたら**、第2節のフォールバックにより `USING` の `user_id = auth.uid()` が新しい行にも適用され、**攻撃A（user_id の書き換え）は弾かれます**。つまりこの攻撃が成立する前提は、`with check` を省略したのではなく**明示的に `true`（または `USING` より緩い式）を書いた**ことにあります。

しかしフォールバックは万能ではありません。`using ( user_id = auth.uid() )` がピン留めしているのは `user_id` 列だけ。**攻撃B（`role` の昇格）は、user_id を変えていないのでフォールバック後も `true` のまま通ります。** 守りたい列が他にもあるなら、`WITH CHECK` でその列も縛る（例：`role = 'user'`）、あるいは「不変にすべき列はそもそもユーザーに更新させない」設計——列単位の権限（`GRANT UPDATE (col) `）や、変更を凍結するトリガ、`RESTRICTIVE` ポリシーの併用——が必要です。**`WITH CHECK` は“新しい行の値”を縛る関門であり、変わってはいけない列をすべて言語化して初めて意味を持ちます。**

---

## 6. ALL ポリシーの罠と、「読み書きを分ける」設計

実務で最も多いのが、1本の `FOR ALL` ですべてまかなおうとするパターンです。

```sql
-- ALL + USING のみ：フォールバックで INSERT/UPDATE の新しい行にも所有権が効く（この点は安全側）
create policy "owner can do all"
on documents for all
to authenticated
using ( user_id = (select auth.uid()) );
```

このコード自体は、第2節のフォールバックにより `INSERT`/`UPDATE` の新しい行も `user_id = auth.uid()` で縛られ、**所有権の観点では安全側**に倒れます。問題は2つです。

1. **意図が読み取れない。** 書き込みの安全性が「省略時のフォールバック」という暗黙の挙動に依存しているため、レビュアーやAIが後から `with check (...)` を足したり `for insert with check (true)` を別に生やした瞬間に、誰も気づかず穴が開きます。
2. **列の昇格は防げない。** `ALL` でも、`USING` がピン留めしない列（`role`・`tenant_id` など）の書き換えは素通りです（第5節と同じ）。

そこで私が推奨するのは、**コマンドごとにポリシーを分け、書き込み系には `WITH CHECK` を必ず明示する**ことです。冗長に見えても、意図がSQLに表れ、変更が局所化され（ETC：Easy To Change）、レビューで穴を発見しやすくなります。

```sql
-- 推奨：読み取りと書き込みを分け、WITH CHECK を“明示”する
create policy "read own documents"   on documents for select to authenticated
  using ( user_id = (select auth.uid()) );

create policy "insert own documents" on documents for insert to authenticated
  with check ( user_id = (select auth.uid()) );

create policy "update own documents" on documents for update to authenticated
  using      ( user_id = (select auth.uid()) )
  with check ( user_id = (select auth.uid()) );

create policy "delete own documents" on documents for delete to authenticated
  using ( user_id = (select auth.uid()) );
```

なお、`anon`（公開鍵）ロールに書き込みポリシーを与えていないかも要確認です。`to authenticated` で対象ロールを絞り、未認証の書き込み経路を作らないのが原則です。そして——RLS をどれだけ正しく書いても、**`service_role` キーは `BYPASSRLS` で RLS を丸ごと飛び越える**ため `WITH CHECK` は一切実行されません。サーバー側で `service_role` を使う経路の所有権強制は別主題で、[IDOR/認可欠陥の検出ガイド](/blog/nextjs-supabase-idor-broken-authorization-rls-detection-guide) に詳述しています。

---

## 7. 検出する：migrations の静的検証と実行時の確認

設計で塞ぐと決めたら、「塞げているか」を検証します。書き込みバイパスは、コード（`supabase/migrations/**.sql`）と実行時の両面から確認できます。

### 7-1. migrations の静的検証

ポリシー定義SQLを読み、次の“形”を機械的に洗い出します。

- 書き込み可能なポリシー（`FOR INSERT` / `FOR UPDATE` / `FOR ALL`）で、**`WITH CHECK` が `true`**になっている
- `FOR INSERT` ポリシーなのに **`WITH CHECK` が無い**（挿入を許可しているのに新しい行を検査していない）
- **`WITH CHECK` が `USING` より緩い**（読めない行を書ける＝非対称）
- 所有権列（`user_id` / `tenant_id` 等）を **`WITH CHECK` が参照していない**

これは正規表現で雑に grep するより、ポリシーの構造を解析して `USING` と `WITH CHECK` を突き合わせるほうが確実です。私が公開しているOSS Aegis は `supabase/migrations` を解析してこれらを検出します。インストール不要で走ります。

```bash
# インストール不要・設定不要でスキャン（WITH CHECK の欠落・true・USING との不一致を検出）
npx @aegiskit/cli scan
```

RLS設定ミス全般（RLS未有効化、`using (true)`、`search_path` 未固定の `SECURITY DEFINER`、`anon` への過剰GRANT など）の体系的な検出は [RLS設定ミスの検出と監査](/blog/supabase-rls-misconfiguration-detection-audit-guide) にまとめています。

### 7-2. 実行時に「書けないこと」を回帰テストにする（pgTAP）

静的検証は“疑う”ところまで。最後は、**別ユーザー／別テナントのコンテキストで書き込みが拒否されること**を実際に確かめ、CIで退行を防ぎます。pgTAP なら、他テナントの `id` を差し込む `INSERT` が RLS で弾かれることをテストにできます。

```sql
-- pgTAP：他テナントの id を差し込む INSERT が RLS で拒否されることを回帰テストにする
begin;
select plan(1);
set local role authenticated;
set local request.jwt.claims to
  '{"sub":"user-a","app_metadata":{"tenant_id":"00000000-0000-0000-0000-0000000000aa"}}';

select throws_ok(
  $$ insert into documents (title, tenant_id)
     values ('x', '00000000-0000-0000-0000-0000000000bb') $$,
  '42501',  -- insufficient_privilege（RLS の WITH CHECK 違反）
  null,
  'authenticated user cannot insert a row for another tenant'
);

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

`UPDATE` 側も同様に、「自分の行の `user_id` を別人に書き換える更新が弾かれること」「`role` を昇格させる更新が弾かれること」をそれぞれ1本ずつ書いておくと、第5節の2つの攻撃に対する回帰ガードになります。

### 7-3. 正直なスコープ

ここは強調させてください。**いかなる静的・動的ツールも、あなたの認可が正しいことは証明しません。** ツールが検出できるのは「`WITH CHECK` が無い／`true` だ／`USING` と非対称だ」という*形*であって、「`tenant_id = app_metadata の tenant_id` という述語が、このシステムのテナント帰属の定義として*正しい*か」という*意味*ではありません。クリーンな結果は「よくある罠は踏んでいない」であって「安全」ではない。これらの検証は、人間のレビューと脅威モデリングを**置き換えるものではなく、補完するもの**です。

---

## 8. 本番前チェックリスト

外注でもAI製でも、本番投入の前に最低限これだけは確認してください。

- [ ] 書き込みを許可する全ポリシー（`FOR INSERT` / `FOR UPDATE` / `FOR ALL`）に **`WITH CHECK` が明示**されている
- [ ] **`with check (true)` がどこにも無い**（エラーを黙らせるための `true` が残っていないか）
- [ ] `FOR INSERT` ポリシーの `WITH CHECK` が、**所有権・テナント列（`user_id` / `tenant_id`）を呼び出し元に縛っている**
- [ ] `FOR UPDATE` の `WITH CHECK` が、**更新後の行の所有権列を縛り**、`user_id` の書き換えを封じている
- [ ] テナント／所有の根拠が **`app_metadata` 等サーバー権限の値**で、`user_metadata`（ユーザー書き換え可能）に依存していない
- [ ] **変えてはいけない列**（`role`・`tenant_id`・`is_admin` 等）が、`WITH CHECK` か列権限かトリガで保護されている
- [ ] `anon` ロールに不要な書き込みポリシーを与えていない（`to authenticated` で絞る）
- [ ] **2アカウント／2テナントで「他人として書く」**を実際に試し、`INSERT`/`UPDATE` が拒否されることを確認した
- [ ] pgTAP 等で「書けないこと」をCIの回帰テストにしている
- [ ] `service_role` を使う書き込み経路は、RLS を飛び越える前提で**コード側で所有権を強制**している

発注者の視点で最も効くのは、**「他人の `user_id` で行を作れますか？」「`with check` は何を検査していますか？」「読めない行を書けないことを、どう確認しましたか？」**の3問です。良い開発者は即答できます。

---

## 9. どこまで自分で、どこから監査か

最後に、正直に線を引きます。

**“形”の検出は自動化できます。** `WITH CHECK` の欠落・`true`・`USING` との非対称は、`npx @aegiskit/cli scan` のような静的検証で機械的に拾えます。まずは [Aegis](/aegis)（無料OSS）で現状を可視化するのが、最もコスパの良い第一歩です。書き込みポリシーに `with check (true)` が一つでも残っていないか——それを一覧化するだけでも、最頻出の事故はかなり防げます。

**一方、“意味”の正しさは人間の領域です。** 「このテーブルのテナント帰属を何で決めるか」「どの列は誰にも書き換えさせないか」「更新で許してよい状態遷移は何か」は、あなたのデータモデルと事業ルールを理解した人間にしか判断できません。ここで「導入すれば書き込みは安全」と言い切る製品は、むしろ危険です。**Aegis は `WITH CHECK` の欠落・`true`・非対称を検出・警告しますが、その述語が業務的に正しいことは証明しません。完全に安全にする魔法はありません。**

だからこそ線引きが要ります。どこまで自分で固め、どこから専門家のレビューが必要か——`WITH CHECK` の述語設計、テナント帰属の根拠、不変列の保護といった**書き込み側の認可設計**のレビューが必要なら、[セキュリティ監査](/aegis/audit)で承ります。私自身、[木材流通業界のDX案件](/case-studies/lumber-industry-dx)で、マルチテナントの読み書き双方の RLS とテナント分離・所有権強制を実運用で設計・検証してきました。

---

## よくある質問（FAQ）

**Q. RLS を有効化して `USING` を書けば、書き込みも守られますか？**
A. いいえ。`USING` は読み取り（既存行）のフィルタです。`INSERT` は `USING` を持てず、書き込みの安全性は `WITH CHECK` だけに懸かっています。`UPDATE` は省略時にフォールバックで `USING` が流用されますが、`with check (true)` を書けば消え、`USING` がピン留めしない列の昇格も防げません。読み取りと書き込みは別の関門だと考えてください。

**Q. `with check (true)` はなぜ危険なのですか？**
A. 「新しい行を一切検査しない」と宣言しているからです。`INSERT`/`UPDATE` 時の `new row violates row-level security policy` エラーを最短で消せるため安易に使われますが、それは保護を外しているだけです。`true` ではなく所有権・テナントを表す述語を書いてください。

**Q. `UPDATE` で `WITH CHECK` を省略するのは危険ですか？**
A. 省略時はフォールバックで `USING` が新しい行にも適用されるため、`USING` がピン留めする列（例：`user_id`）の書き換えは弾かれます。ただし**それに頼るのは危険**です。`USING` が縛らない列（`role` など）は素通りしますし、暗黙の挙動はレビューで見落とされやすい。書き込み系は `WITH CHECK` を明示し、変えてはいけない列をすべて言語化するのが安全です。

**Q. `service_role` を使えば `WITH CHECK` は関係ない？**
A. 関係ない、というより**危険な意味で無関係**です。`service_role` は `BYPASSRLS` で RLS を完全に飛び越えるため、`WITH CHECK` は実行されません。サーバー側で `service_role` を使う経路では、RLS に頼れない前提でコード側に所有権・テナントの強制を書く必要があります（[IDOR/認可欠陥の検出ガイド](/blog/nextjs-supabase-idor-broken-authorization-rls-detection-guide)）。

**Q. ツールを入れれば書き込みバイパスは無くなりますか？**
A. なくなりません。静的検証は `WITH CHECK` の欠落・`true`・`USING` との非対称という“形”を検出・警告できますが、述語が業務的に正しいかは判断しません。検出（ツール）と設計（人間）と検証（テスト）の三つで初めて本番品質になります。

---

## まとめ：読み取りを縛っても、書き込みは縛られない

要点を整理します。

- RLS には条件式の置き場所が2つある。**`USING` は「どの既存行が見えるか」を決める読み取りフィルタ、`WITH CHECK` は「どの新しい行を書けるか」を決める書き込みフィルタ**。SELECT/DELETE は `USING` のみ、INSERT は `WITH CHECK` のみ、UPDATE は両方が効く。
- **`WITH CHECK` を欠いた（または `true` にした）書き込みポリシーは『書き込みバイパス』を開く**。INSERT で他テナントの `id` を差し込み、UPDATE で行の `user_id` を他人に書き換えられる。読み取りのテストでは見えない。
- `UPDATE`/`ALL` の**フォールバック**（省略時に `USING` を流用）は安全網だが頼ってはいけない。**INSERT には無く、`with check (true)` で消え、`USING` がピン留めしない列は守れない**。
- 量産の引き金は「`new row violates row-level security policy` を `with check (true)` で黙らせる」誘惑と、ハッピーパス外で顕在化しない書き込み欠陥。CVE-2025-48757 はその深刻な実例。
- 検出は migrations の静的検証（`npx @aegiskit/cli scan`）＋ pgTAP で「書けないこと」を回帰テスト化。ただし**ツールは“形”を検出するだけで、述語の“正しさ”は人間の設計とレビューでしか守れない**。

AIで速く作ること自体は正しい。**速く作ったものの“書き込み側”を、漏らさず固める**——その `WITH CHECK` 設計や、既存の Supabase アプリの読み書き双方の RLS レビューが必要であれば、お気軽にご相談ください。

---

## 参考資料

- [PostgreSQL — CREATE POLICY（USING と WITH CHECK、コマンド別の適用、省略時のフォールバック）](https://www.postgresql.org/docs/current/sql-createpolicy.html)
- [PostgreSQL — Row Security Policies（RLSの全体像とポリシーの挙動）](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)
- [Supabase Docs — Row Level Security（service_roleはRLSをバイパスする）](https://supabase.com/docs/guides/database/postgres/row-level-security)
- [NVD — CVE-2025-48757（不十分なRLSによる未認証の読み書き、CWE-863、CVSS 9.3）](https://nvd.nist.gov/vuln/detail/CVE-2025-48757)
- [OWASP API1:2023 — Broken Object Level Authorization（BOLA/IDOR、API最頻出リスク）](https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/)
