# Supabase / PostgreSQL のSQLインジェクション対策 — rpc・生SQL・関数内の動的SQLの罠

> SupabaseはPostgRESTとパラメータ化クエリで素のSQLインジェクションが起きにくい。だが関数内の動的SQL（EXECUTEと文字列連結）・format()の%s誤用・生SQL・検索フィルタの組み立ては注入経路になる。EXECUTE USINGのパラメータ化、format()の%I/%L、quote_ident/literal、許可リスト検証で安全にする方法を、脆弱→修正の実SQL/TSで解説します。

- 公開日: 2026-06-28
- 著者: 友田 陽大
- タグ: Supabase, PostgreSQL, セキュリティ, TypeScript
- URL: https://tomodahinata.com/blog/supabase-postgres-sql-injection-rpc-prevention-guide
- カテゴリ: アプリ層セキュリティ
- 総合ガイド: https://tomodahinata.com/blog/nextjs-supabase-application-security-guide

## 要点

- SupabaseでSQLインジェクションが起きにくいのは、PostgRESTと構造化API（.eq など）が値をパラメータとして渡すから。だが安全な層の外側——関数内の動的SQL・生SQL・検索フィルタ文字列の組み立て——では普通に注入が起きる
- 最大の罠は SECURITY DEFINER 関数の中の動的SQL（EXECUTE と文字列連結）。汚染入力がSQL本文に混ざると、所有者権限でRLSを越えて全テーブルを抜かれる。rpc() の引数渡し自体は安全でも、関数の中身が注入を再導入する
- 安全パターンは4つ——(1) そもそも動的SQLを使わない、(2) 値は EXECUTE … USING $1 でパラメータ化、(3) 識別子は format の %I ＋許可リスト、(4) リテラルは %L。%s は無検査の文字列置換で識別子注入の温床
- 識別子は $1 でバインドできないため %I（quote_ident）で囲むが、これは構文の破壊を防ぐだけで『どの列を見せてよいか』までは判定しない。識別子の本命の防御は許可リスト検証
- 汚染入力→SQLシンクへの到達は taint 解析で機械的に検出できる。だがサニタイザ（%I・%L・許可リスト）が妥当か、動的SQLの設計が正しいかは、人間のレビューでしか担保できない

---

最初に結論を述べます。**Supabase / PostgreSQL は「素のSQLインジェクション（SQLi）」が起きにくいスタックです。** PostgRESTが公開するREST APIも、`supabase-js` の構造化API（`.eq()`・`.ilike()` など）も、入力を文字列としてSQL文に埋め込むのではなく **値をパラメータとして渡す** ため、入力がSQLの一部として解釈される余地がそもそも小さい。ですが——**「起きにくい」は「起きない」ではありません。** 安全な層の"外側"、すなわち **関数（RPC）の中の動的SQL・アプリ側の生SQL・検索フィルタ文字列の組み立て** では、SQLiは今でも普通に起きます。

本記事は、Supabaseで「SQLiが起きにくい場所」と「それでも起きる場所」の地図を引き直し、攻撃がどう成立するか、そして **パラメータ化・`format()` の `%I`/`%L`・`quote_ident`/`quote_literal`・許可リスト検証** でどう塞ぐかを、脆弱→修正の実SQL/TSで示します。SQLインジェクションは [OWASP Top 10](https://owasp.org/www-project-top-ten/) の Injection として長年トップクラスに位置づけられる古典ですが、Supabaseという組み合わせには特有の"再導入経路"があります。注入を含むアプリ層セキュリティ全体の地図は[Next.js × Supabase アプリケーションセキュリティ完全ガイド](/blog/nextjs-supabase-application-security-guide)にあり、本記事はその中の「SQLi」に絞って深掘りするものです。

---

## 1. なぜSupabaseでは「素のSQLi」が起きにくいのか

最初に、安全な側を正確に理解します。ここを誤解すると、安全な所を過剰に疑い、危険な所を見落とします。

SupabaseのデータアクセスはPostgREST経由が基本で、`supabase-js` の構造化APIはフィルタの **値** をクエリ文字列のパラメータとして送ります。サーバー側のPostgRESTは、それを準備済みステートメントの **バインド変数** としてPostgreSQLに渡します。つまり、ユーザー入力は最後まで「データ」であって「SQL文」にはなりません。

```ts
// 安全：値は最後までパラメータ。SQL文として解釈されない
const q = new URL(req.url).searchParams.get("q") ?? "";
const { data } = await supabase
  .from("posts")
  .select("title, body")
  .eq("status", "published")
  .ilike("title", `%${q}%`); // q は「値」。区切り文字を仕込んでもSQLは壊れない
```

`q` に `'; drop table posts; --` を入れても、それは「そういう文字列を含むタイトルを探す」だけで、SQL文の構造には一切影響しません。**構造化APIを「値渡し」で使っている限り、SQLiは構造的に起きない。** これがSupabaseの初期設定が安全寄りである理由です。

問題は、開発者（やAIエージェント）が **この安全な層の外に出る瞬間** に生まれます。次節で、その境界を地図にします。

---

## 2. では、どこで起きるのか——4つの危険地帯

SQLiが再導入されるのは、決まって以下の4箇所です。共通点は「**入力がSQL文の"文字列"として組み立てられる**」こと。値ではなく文になった瞬間に、注入の扉が開きます。

| 危険地帯 | 何が起きるか | 主因 |
|---|---|---|
| 関数内の動的SQL（EXECUTE） | RPCの中で入力を連結してSQL文を組む | 文字列連結 / `%s` |
| アプリ側の生SQL | `sql.unsafe` や生クエリに入力を連結 | エスケープなしの連結 |
| 検索フィルタの組み立て | `.or()`/`.filter()` のフィルタ文字列を連結 | PostgRESTフィルタ injection |
| 任意SQL実行関数の公開 | `exec_sql(text)` のような汎用関数を晒す | 注入そのものをAPI化 |

このうち最も深刻で、最も見落とされやすいのが1つめ——**関数（RPC）の中の動的SQL** です。`rpc()` の呼び出し自体は安全に見えるため、危険が"関数の中"に隠れます。まずここを徹底的に分解します。

---

## 3. 動的SQLの本質——「EXECUTEで実行する文字列」だけが危ない

PL/pgSQLでは、SQL文を文字列として組み立ててから `EXECUTE` で実行できます（動的SQL）。SQLiの議論で最も重要なのは、**「変数を値として使うこと」と「変数をSQL文の文字列に連結すること」はまったくの別物** だという一点です。ここが混同の温床です。

```sql
-- (A) 危険：入力を「SQL本文の文字列」に連結してから EXECUTE する
--     keyword は SQL の一部として解釈される＝古典的SQLi
execute 'select * from documents where title = ''' || keyword || '''';

-- (B) 安全：通常の文（EXECUTE なし）。keyword は「値」として束縛される
--     ここでの || は文字列“値”の連結であって、SQL本文の組み立てではない
select * from documents where title = keyword;                   -- 完全一致
select * from documents where title ilike '%' || keyword || '%'; -- 部分一致
```

(B) の `||` を見て「連結しているから危険」と早合点しないでください。(B) では `keyword` は実行時に評価される **テキスト値** にすぎず、PostgreSQLはそれをパラメータとして扱います。SQL文の構造は固定です。**注入が起きるのは、組み立てた文字列を `EXECUTE` に渡したとき（A）だけ。** 言い換えれば——

> **静的に書ける処理を、不要に `EXECUTE` で動的化しないこと。** これが第一の防御です。動的SQLは、テーブル名や列名を実行時に変える等、本当に必要なときだけの道具です。

動的SQLが本当に必要なときの安全な書き方は第6節にまとめます。その前に、Supabase特有の"見えにくさ"——`rpc()` を見ます。

---

## 4. rpc() の落とし穴——引数渡しは安全、関数の中身が危ない

SupabaseはPostgreSQLの関数を `rpc()` でAPI公開できます。ここで多くの人が誤解します。**`rpc()` の引数受け渡し自体は安全**です。PostgRESTが引数をパラメータとしてバインドするため、引数がSQL文に化けることはありません。危険なのは **関数の本体が、その引数で動的SQLを組み立てるとき** です。

```sql
-- 脆弱：検索語を文字列連結で動的SQLに埋め込む RPC
create or replace function search_documents(keyword text)
returns setof documents
language plpgsql
security definer                       -- 所有者権限で実行＝RLSを越える（後述）
as $$
begin
  return query execute
    'select * from documents where title ilike ''%' || keyword || '%''';
end;
$$;
```

```ts
// アプリ側：rpc の呼び出しはパラメータ化される（ここは安全）
const q = new URL(req.url).searchParams.get("q") ?? ""; // ← 汚染入力（source）
const { data } = await supabase.rpc("search_documents", { keyword: q });
// だが keyword は関数の中で SQL本文に連結される＝注入は“関数の中”で起きる
```

攻撃は古典そのものです。`q` に次を入れます。

```text
%' union select * from auth_tokens --
```

組み立てられるSQLはこうなります。

```sql
select * from documents where title ilike '%%' union select * from auth_tokens --%'
```

`union` で別テーブルを連結し、`--` で末尾を無効化。**`documents` に完璧なRLSが張ってあっても無関係** です。理由は次節。

---

## 5. SECURITY DEFINER が被害を最大化する

第4節の関数は `security definer` でした。これは関数を **定義者（所有者）の権限で実行する** 設定で、典型的には所有者がテーブル所有者＝RLSを越えられるロールです（[PostgreSQL: CREATE FUNCTION](https://www.postgresql.org/docs/current/sql-createfunction.html)）。つまり——

- `security invoker`（既定）の関数内SQLi → 呼び出したユーザーの権限の範囲で被害が止まる
- `security definer` の関数内SQLi → **所有者権限でRLSを丸ごと飛び越え**、全テーブルを読み書きされる

Supabase公式も、`service_role` 同様にRLSを越える経路の存在を明記しています（[Supabase: Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security)）。**動的SQLと `SECURITY DEFINER` の組み合わせは、SQLiの被害を「テナント内の一部」から「データベース全体」へ拡大する増幅器** です。だからこの2つが重なる関数は、最優先で監査すべき対象になります。

`SECURITY DEFINER` 自体の正しい使い方（`search_path` の固定によるスキーマ乗っ取り対策、最小権限の所有者設計など）は論点が多いため、[SECURITY DEFINER関数とsearch_pathの安全設計](/blog/supabase-security-definer-function-search-path-guide)に切り出しています。本記事では「定義者権限の関数で動的SQLを組むなら、入力は絶対に文字列連結しない」という原則だけ徹底します。

---

## 6. 安全パターン——動的SQLが必要なときの4つの道具

ここからが対策の本体です。動的SQLが避けられないとき、入力の種類（**値 / 識別子 / キーワード**）ごとに正しい道具が違います。混ぜると穴が開きます。

### 6-1. 値は EXECUTE … USING のパラメータに分離する

最も安全で、最も使うべき手段です。`$1` プレースホルダと `USING` 句で値を渡すと、入力は文字列連結を一切経由せず、バインド変数として扱われます。

```sql
-- 安全：値は $1 として USING で渡す（SQL本文には混ざらない）
execute 'select * from documents where title ilike $1'
  using '%' || keyword || '%'
  into result;
-- '%' || keyword || '%' は“値”の連結。$1 にバインドされるだけで構造は不変
```

### 6-2. 識別子は format('%I')——ただし本命は「許可リスト」

`$1` は **値にしか使えません**。テーブル名・列名などの **識別子** はパラメータ化できないため、`format()` の `%I`（内部で `quote_ident` を呼ぶ）で囲みます。`%I` は識別子として安全に引用符付けし、構文の破壊を防ぎます。

```sql
-- 識別子は %I、値は $1。両者を絶対に混ぜない
execute format('select count(*) from %I where owner_id = $1', target_table)
  using auth.uid()
  into result_count;
```

ここで **正直に線を引きます**。`%I`（`quote_ident`）が防ぐのは「識別子から抜け出して別のSQLを差し込む」という **構文上の注入** だけです。**「その識別子を見せてよいか」という業務的な判断はしません。** 攻撃者が `target_table` に実在する別テーブル（例：`auth_secrets`）を指定すれば、`%I` は何も警告せずそれを引用符付けして通します。だから識別子の本命の防御は **許可リスト検証** です。

```sql
create or replace function list_orders(sort_col text, dir text)
returns setof public.orders
language plpgsql
security invoker                      -- まず DEFINER が本当に要るか問い直す
set search_path = ''                  -- スキーマ乗っ取り対策（詳細は別記事）
as $$
begin
  -- 識別子（列名）は許可リストで検証する。%I だけでは「実在する別の列」を防げない
  if sort_col not in ('created_at', 'total_amount', 'status') then
    raise exception 'invalid sort column: %', sort_col;
  end if;
  -- 並び順は“キーワード”。値にも識別子にもできない＝許可リスト必須
  if upper(dir) not in ('ASC', 'DESC') then
    raise exception 'invalid sort direction: %', dir;
  end if;

  return query execute
    format('select * from public.orders order by %I %s', sort_col, dir);
end;
$$;
```

`dir` を `%s`（無検査の文字列置換）で埋めているのに安全なのは、**直前で許可リスト検証を通したから** です。`ASC`/`DESC` のようなSQLキーワードは値にも識別子にもできず、許可リスト→検証済みの値だけを置換、という順序でしか安全にできません。`%s` を使ってよいのは、このように「すでに自分で正当性を保証した値」だけです。

### 6-3. リテラルは %L（または quote_literal/quote_nullable）

値をどうしても文字列に埋め込む必要がある場面では、`%L`（内部で `quote_literal`）を使います。`%L` は文字列リテラルとして安全に引用符付けし、`NULL` も正しく `NULL` リテラルに変換します。

```sql
-- 安全：%L が keyword を安全なリテラルにエスケープする
execute format('select * from documents where title ilike %L', '%' || keyword || '%');
```

`quote_literal` を素で使うときの罠も押さえます。`quote_literal(NULL)` は文字列の `'NULL'` ではなく **SQLのNULL** を返すため、連結すると壊れたSQLになります。NULLを取りうる値には `quote_nullable` を使うか、上記のように `format` の `%L` に任せるのが安全です。

### 6-4. %s は「識別子・キーワードに対しては」使わない

`format()` の `%s` は `sprintf` 的な **無検査の文字列置換** です。識別子やキーワードに `%s` を使うと、`%I` の保護を失い、識別子注入を許します。

```sql
-- 脆弱：識別子に %s。quote_ident を経ないので注入できる
execute format('select * from %s', user_supplied_table);

-- 修正：識別子は %I（＋許可リスト）
execute format('select * from %I', user_supplied_table);
```

4つの道具を一枚に整理します。

| 入力の種類 | 正しい道具 | 補足 |
|---|---|---|
| 値（VALUES, WHERE の比較値） | `$1` ＋ `USING` | 最優先。連結を一切しない |
| リテラルとして埋め込む値 | `%L` / `quote_literal` | NULLは `%L` か `quote_nullable` |
| 識別子（テーブル名・列名） | `%I`（＋許可リスト） | `%I` は構文のみ守る。妥当性は許可リスト |
| キーワード（ASC/DESC 等） | 許可リスト ＋ 検証後に `%s` | 値にも識別子にもできない |

---

## 7. アプリ側の生SQL——プレースホルダか、タグ付きテンプレートで

関数の外、アプリのサーバーコードから直接SQLを投げる場合（`postgres.js`・`node-postgres` など）も、原則は同じ「連結しない」です。

```ts
import postgres from "postgres";
const sql = postgres(process.env.DATABASE_URL!);

const name = new URL(req.url).searchParams.get("name") ?? ""; // ← 汚染入力（source）

// 脆弱：sql.unsafe に文字列連結（生SQLのインジェクション）
const bad = await sql.unsafe(`select * from users where name = '${name}'`);

// 修正：タグ付きテンプレートが ${} を $1 プレースホルダに変換する（連結しない）
const good = await sql`select * from users where name = ${name}`;
```

```ts
// node-postgres：プレースホルダ＋値配列。文字列連結は絶対にしない
await client.query("select * from users where id = $1 and org_id = $2", [id, orgId]);
```

ライブラリのタグ付きテンプレート（`` sql`...${value}...` ``）は、`${value}` を自動でバインド変数に変換するため安全です。危険なのは、その安全装置を **明示的に外す** `sql.unsafe(...)` に入力を連結したときだけ。`unsafe` という名前は伊達ではありません。

そして、最悪の反パターンが「任意SQLを実行する汎用関数」の公開です。

```sql
-- 絶対NG：任意SQLを実行する汎用関数。rpc 経由で誰でも叩ける“注入のAPI化”
create function exec_sql(query text) returns void
language plpgsql security definer as $$
begin execute query; end;
$$;
-- supabase.rpc('exec_sql', { query: '...' }) で任意SQLが通る。存在自体が脆弱性
```

これは「注入の脆弱性」というより「**注入をそのままAPIにした**」もので、`SECURITY DEFINER` なら一撃でDB全体が陥落します。便利だからと作ってはいけません。

---

## 8. 検索フィルタの組み立て——.or() のPostgRESTフィルタ injection

最後の危険地帯は、`supabase-js` の `.or()`/`.filter()` に **生のフィルタ文字列** を渡すケースです。

```ts
const q = new URL(req.url).searchParams.get("q") ?? "";

// 脆弱：検索語を .or() のフィルタ文字列に連結（PostgRESTフィルタ injection）
await supabase.from("posts").select("*").or(`title.ilike.*${q}*,body.ilike.*${q}*`);
// q に "," や "*"、")" を仕込むとフィルタ文法を壊し、別の条件を足して別の行を引ける

// 修正：構造化APIで「値」として渡す（フィルタ文字列を組み立てない）
await supabase.from("posts").select("title, body").ilike("title", `*${q}*`);
```

**正直に区別します。** これは厳密にはPostgreSQLレベルの生SQLiではありません。PostgRESTは最終的なSQLをパラメータ化するため、`drop table` のようなSQL文を直接注入することはできない。しかし `.or()` のフィルタ **文法** を壊して条件を改ざんできるため、「どの行が返るか」を攻撃者が操作できます。RLSが弱いテーブルと組み合わさると、認可の前提が崩れます。**論理的な注入**として、れっきとした注入クラスの一員です。構造化API（`.eq`・`.ilike` 等）に値を渡せば、フィルタ文字列を組み立てる必要自体がなくなります。

---

## 9. taint解析——汚染入力からSQLシンクへの到達を追う

ここまでの危険地帯は、見た目はバラバラですが構造は同じです。**クライアントが操作できる入力（source）が、検証されないままSQL文を組み立てる処理（sink）に到達する。** だから正規表現ではなく、データフロー解析（taint解析）で機械的に追えます。

| 汚染入力（source） | 危険シンク（sink） | なぜ危ないか |
|---|---|---|
| rpc の引数 / searchParams / リクエストボディ | 関数内の EXECUTE ＋文字列連結 | 入力がSQL本文に混ざる（古典的SQLi） |
| 同上 | format の %s（識別子・キーワード） | 無検査の置換＝識別子注入 |
| 同上 | sql.unsafe / 生クエリの文字列連結 | アプリ層の生SQLインジェクション |
| 同上 | .or() / .filter() のフィルタ文字列 | PostgRESTフィルタ injection（論理的注入） |

source→sink の経路上に **妥当なサニタイザ**（`$1`/`USING`、`%I`+許可リスト、`%L`、構造化API）があるかを見れば、危険な流れを自動で拾えます。SSRFやパストラバーサルも「source→sink」という同じ骨格で、入力をURLやパスのシンクに通すか否かで判定できます（[SSRF対策ガイド](/blog/nextjs-ssrf-prevention-server-actions-route-handlers-guide)では同じ構造を別シンクで扱っています）。

> **ここで正直なスコープを引きます。** taint解析が機械化できるのは「汚染入力がサニタイザを経ずにSQLシンクへ到達したか」までです。**サニタイザが"妥当か"——許可リストが正しい集合を網羅しているか、`%I` で囲った識別子の選択が業務的に妥当か——はツールには判定できません。** データフロー解析は関数内（intraprocedural）が基本で、関数が別の関数を呼ぶ流れや、文字列を組み立てて別の場所へ渡す流れは見逃します。検出は「よくある罠を踏んでいないこと」を示すだけで、「安全であること」を証明はしません。**安全なクエリ設計は、最後は人間のレビューが要る**——これが本記事の一貫した立場です。

---

## 10. 検出——npx で source→SQL sink を可視化する

設計で塞ぐと決めたら、「塞げているか」を継続的に検証します。私が公開しているOSS Aegis は、上のtaint解析（汚染入力→SQLシンク）に加え、`supabase/migrations/**.sql` を読んで **危険な動的SQLのパターン**（`EXECUTE` への文字列連結、`format` の `%s` 識別子、`SECURITY DEFINER` ＋動的SQL、`exec_sql` 的な汎用関数）を洗い出します。インストール不要で走ります。

```bash
# インストール不要・設定不要。汚染入力→SQLシンクの到達と、
# migrations 内の危険な動的SQL（EXECUTE連結・format %s・DEFINER）を可視化
npx @aegiskit/cli scan
```

動的な確認も併用すると確度が上がります。SQLiの再現可否は、**自分が所有する環境** に対して入力（区切り文字・`union`・コメント `--` 等）を与え、エラーや想定外の行が返るかで確かめます。テスト観点はOWASPのテスティングガイドが体系的です（[OWASP Web Security Testing Guide](https://owasp.org/www-project-web-security-testing-guide/)）。静的解析の「疑い」と動的の「再現」が一致したものを最優先で直す、という相関が効きます。

---

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

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

- [ ] 関数内の動的SQLで、**値は `EXECUTE … USING $1` に分離**している（SQL本文へ連結しない）
- [ ] **識別子は `format` の `%I`** で囲み、かつ **許可リストで検証**している
- [ ] **リテラルは `%L`**（または `quote_literal`/`quote_nullable`）。素の連結をしていない
- [ ] `format` の **`%s` を識別子・キーワードに使っていない**（使うなら許可リスト検証の後だけ）
- [ ] **静的に書ける関数を、不要に `EXECUTE` で動的化していない**
- [ ] `SECURITY DEFINER` は本当に必要な関数だけ。`search_path` を固定している
- [ ] アプリ側の生SQLは **プレースホルダ（`$1`）かタグ付きテンプレート**。`sql.unsafe` に入力を連結していない
- [ ] **任意SQLを実行する汎用関数（`exec_sql` 等）を公開していない**
- [ ] 検索は `.eq`/`.ilike` など構造化APIで値渡し。**`.or()` にユーザー入力を連結していない**
- [ ] **汚染入力→SQLシンクの静的解析をCIに常設**している

発注者の視点で効く質問は3つです。**「動的SQL（`EXECUTE`）を使っている関数はどれですか？」「`SECURITY DEFINER` の関数で入力をどう扱っていますか？」「検索はどう組み立てていますか？」**——良い開発者は即答できます。

---

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

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

**汚染入力→SQLシンクの到達検出は、自動化で機械的に潰せます。** `EXECUTE` への連結、`%s` の識別子置換、`sql.unsafe` への連結、`.or()` への入力連結——これらは構造が定型的なので、静的解析をCIに入れれば人間が毎回見張る必要はありません。まずは [Aegis](/aegis)（無料OSS、`npx @aegiskit/cli scan`）で現状を可視化するのが、最もコスパの良い第一歩です。

一方、**「サニタイザが妥当か」「動的SQLの設計が正しいか」は人間の領域**です。許可リストが正しい集合か、`SECURITY DEFINER` を使う必然性があるか、その関数がRLSを越えてよい範囲はどこまでか——これらは、あなたのデータモデルと業務ルールを理解した人間にしか判断できません。**「このツールを入れればSQLiは完全に安全」と言い切る製品は、むしろ危険です。** ツールは検出を助けますが、クエリ設計の正しさは証明しません。

だからこそ線引きが要ります。動的SQLを含む関数の設計修正や、既存のSupabaseアプリのRPC・RLS・認可レビューが必要なら、[セキュリティ監査](/aegis/audit)で承ります。私自身、[木材流通業界のDX案件](/case-studies/lumber-industry-dx)で、221本のAPIとPostgreSQLの関数・RLSを含むデータ層の認可を実運用で設計・検証してきました。AIで速く作ること自体は正しい。**速く作ったものを、漏らさず安全に固める**——その仕組みづくりが本筋です。

---

## よくある質問（FAQ）

**Q. supabase-js の `.eq()` や `.ilike()` を使っていればSQLiは起きませんか？**
A. その経路では起きません。構造化APIに **値** を渡している限り、入力はパラメータとして扱われ、SQL文の構造に影響しません。起きるのは、`.or()` にフィルタ文字列を連結する、RPCの中で動的SQLを組む、`sql.unsafe` を使う、といった「安全な層の外」に出たときだけです。

**Q. `rpc()` を使うとSQLiの危険が増えますか？**
A. `rpc()` の呼び出し（引数渡し）自体は安全で、引数はパラメータとしてバインドされます。危険なのは **関数の本体** が、その引数を文字列連結で動的SQLに埋め込む場合です。危険は「`rpc` を使うこと」ではなく「関数の中身」にあります。

**Q. `format()` を使えば安全ですか？**
A. プレースホルダ次第です。値は `%L`、識別子は `%I` なら安全側ですが、`%s` は無検査の文字列置換なので識別子・キーワードに使うと注入を許します。さらに `%I` は構文の破壊を防ぐだけで「その識別子を見せてよいか」は判定しません。識別子は `%I` ＋ **許可リスト** の二段で守ってください。

**Q. RLSを張っていればSQLiは問題になりませんか？**
A. なりません。`SECURITY DEFINER` 関数の中のSQLiは **RLSを丸ごと飛び越え** ます。逆に `SECURITY INVOKER` でも、注入で意図しない行を引けば認可の前提は崩れます。RLSは必須ですが、SQLi対策（パラメータ化・許可リスト）は別レイヤーとして両方必要です。

**Q. AIに「セキュアに書いて」と頼めば動的SQLは安全になりますか？**
A. 期待しすぎないでください。AIは「動くRPC」を最短で書きますが、`EXECUTE` への連結や `%s` の誤用はデモでは顕在化しません。検証ゲート（静的解析・テスト・レビュー）を通して初めて本番品質になります。

---

## まとめ：安全な層の"外"に出る瞬間を見張る

要点を整理します。

- Supabaseで「素のSQLi」が起きにくいのは、PostgRESTと構造化APIが **値をパラメータとして渡す** から。だが **安全な層の外側**——関数内の動的SQL・生SQL・検索フィルタの組み立て——では普通に起きる。
- 最大の罠は **`SECURITY DEFINER` 関数の中の動的SQL（`EXECUTE` ＋連結）**。汚染入力がSQL本文に混ざると、所有者権限でRLSを越えて全テーブルを抜かれる。`rpc()` の引数渡し自体は安全でも、関数の中身が注入を再導入する。
- 安全パターンは入力の種類で道具を分ける——**値は `$1`/`USING`、リテラルは `%L`、識別子は `%I` ＋許可リスト、キーワードは許可リスト後に `%s`**。そして **静的に書ける処理を不要に動的化しない**。
- 汚染入力→SQLシンクの到達は **taint解析で機械的に検出できる**。だが **サニタイザの妥当性とクエリ設計の正しさは、人間のレビューでしか担保できない**。「入れれば完全に安全」な製品は存在しない。

既存のSupabaseアプリのRPC・動的SQL・RLSのレビューや、注入検出をCIに組み込む仕組みづくりが必要であれば、お気軽にご相談ください。

---

## 参考資料

- [OWASP Top 10（Injection を含むWebアプリの主要リスク）](https://owasp.org/www-project-top-ten/)
- [PostgreSQL — CREATE FUNCTION（SECURITY DEFINER / INVOKER、関数の権限）](https://www.postgresql.org/docs/current/sql-createfunction.html)
- [Supabase Docs — Row Level Security（定義者権限・service_role はRLSを越える）](https://supabase.com/docs/guides/database/postgres/row-level-security)
- [OWASP Web Security Testing Guide（注入のテスト観点）](https://owasp.org/www-project-web-security-testing-guide/)
