# SQL-injection prevention for Supabase / PostgreSQL — the traps of rpc, raw SQL, and dynamic SQL inside functions

> Supabase makes plain SQL injection unlikely with PostgREST and parameterized queries. But dynamic SQL inside functions (EXECUTE and string concatenation), misuse of format()'s %s, raw SQL, and the assembly of search filters become injection routes. This explains, with vulnerable→fixed real SQL/TS, how to make it safe with EXECUTE USING parameterization, format()'s %I/%L, quote_ident/literal, and allowlist verification.

- Published: 2026-06-28
- Author: 友田 陽大
- Tags: Supabase, PostgreSQL, セキュリティ, TypeScript
- URL: https://tomodahinata.com/en/blog/supabase-postgres-sql-injection-rpc-prevention-guide
- Category: Application-layer security
- Pillar guide: https://tomodahinata.com/en/blog/nextjs-supabase-application-security-guide

## Key points

- SQL injection is unlikely in Supabase because PostgREST and the structured API (.eq, etc.) pass values as parameters. But outside the safe layer — dynamic SQL inside functions, raw SQL, the assembly of search-filter strings — injection occurs normally.
- The biggest trap is dynamic SQL inside a SECURITY DEFINER function (EXECUTE and string concatenation). When tainted input mixes into the SQL body, all tables are siphoned with the owner's privileges, crossing RLS. Even if rpc()'s argument passing itself is safe, the function's contents reintroduce injection.
- There are four safe patterns — (1) don't use dynamic SQL in the first place, (2) parameterize values with EXECUTE … USING $1, (3) for identifiers, use format's %I + an allowlist, (4) for literals, %L. %s is an uninspected string substitution and a hotbed of identifier injection.
- Identifiers can't be bound with $1, so wrap them in %I (quote_ident), but this only prevents breaking the syntax and doesn't judge 'which column may be shown.' The real defense for identifiers is allowlist verification.
- The reach from tainted input to a SQL sink can be mechanically detected with taint analysis. But whether the sanitizer (%I, %L, allowlist) is valid, and whether the dynamic-SQL design is correct, can only be guaranteed by human review.

---

Let me state the conclusion first. **Supabase / PostgreSQL is a stack where "plain SQL injection (SQLi)" is unlikely.** Both the REST API that PostgREST exposes and `supabase-js`'s structured API (`.eq()`, `.ilike()`, etc.) **pass values as parameters** rather than embedding the input as a string in the SQL statement, so there's little room from the start for the input to be interpreted as part of the SQL. But — **"unlikely" isn't "never."** Outside the safe layer, namely in **dynamic SQL inside a function (RPC), raw SQL on the app side, and the assembly of search-filter strings,** SQLi still occurs normally.

This article redraws the map of "places where SQLi is unlikely" and "places where it still occurs" in Supabase, how the attack holds, and then how to plug it with **parameterization, `format()`'s `%I`/`%L`, `quote_ident`/`quote_literal`, and allowlist verification,** with vulnerable→fixed real SQL/TS. SQL injection is a classic long positioned at the top class as Injection in the [OWASP Top 10](https://owasp.org/www-project-top-ten/), but the Supabase combination has a peculiar "reintroduction route." The overall map of application-layer security including injection is in the [Next.js × Supabase application-security complete guide](/blog/nextjs-supabase-application-security-guide); this article deep-dives, narrowed to "SQLi" within it.

---

## 1. Why "plain SQLi" is unlikely in Supabase

First, accurately understand the safe side. Misunderstanding here makes you over-suspect the safe places and overlook the dangerous ones.

Supabase's data access is basically via PostgREST, and `supabase-js`'s structured API sends the filter's **value** as a query-string parameter. The server-side PostgREST passes it to PostgreSQL as a **bind variable** of a prepared statement. In other words, the user input stays "data" to the end and doesn't become a "SQL statement."

```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は壊れない
```

Even if you put `'; drop table posts; --` in `q`, it just "searches for a title containing that string" and has no effect at all on the SQL statement's structure. **As long as you use the structured API by "value passing," SQLi structurally doesn't occur.** This is the reason Supabase's initial configuration is safety-leaning.

The problem arises the moment a developer (or an AI agent) **steps outside this safe layer.** In the next section, let's map out that boundary.

---

## 2. So where does it occur — the four danger zones

SQLi is reintroduced, invariably, in the following four places. The common point is "**the input is assembled as a 'string' of the SQL statement.**" The moment it becomes a statement rather than a value, the door to injection opens.

| Danger zone | What happens | Main cause |
|---|---|---|
| Dynamic SQL inside a function (EXECUTE) | Concatenate input inside an RPC to build a SQL statement | string concatenation / `%s` |
| Raw SQL on the app side | Concatenate input into `sql.unsafe` or a raw query | concatenation without escaping |
| Assembly of a search filter | Concatenate the filter string of `.or()`/`.filter()` | PostgREST filter injection |
| Exposing an arbitrary-SQL-execution function | Expose a general-purpose function like `exec_sql(text)` | turning injection itself into an API |

Of these, the most serious and most easily overlooked is the first — **dynamic SQL inside a function (RPC).** Since the `rpc()` call itself looks safe, the danger hides "inside the function." Let me thoroughly decompose this first.

---

## 3. The essence of dynamic SQL — only "a string executed with EXECUTE" is dangerous

In PL/pgSQL, you can assemble a SQL statement as a string and then execute it with `EXECUTE` (dynamic SQL). The most important point in the SQLi discussion is the single fact that **"using a variable as a value" and "concatenating a variable into the SQL statement's string" are completely different things.** This is the hotbed of confusion.

```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 || '%'; -- 部分一致
```

Don't jump to "concatenation, so dangerous" looking at (B)'s `||`. In (B), `keyword` is merely a **text value** evaluated at runtime, and PostgreSQL treats it as a parameter. The SQL statement's structure is fixed. **Injection occurs only when you pass the assembled string to `EXECUTE` (A).** In other words —

> **Don't unnecessarily make a statically-writable process dynamic with `EXECUTE`.** This is the first defense. Dynamic SQL is a tool only for when it's genuinely needed, like changing a table name or column name at runtime.

The safe way to write dynamic SQL when it's genuinely needed is summarized in section 6. Before that, let me look at the Supabase-specific "hard-to-see-ness" — `rpc()`.

---

## 4. The pitfall of rpc() — argument passing is safe, the function's contents are dangerous

Supabase can expose a PostgreSQL function as an API with `rpc()`. Here many people misunderstand. **`rpc()`'s argument passing itself is safe.** Since PostgREST binds arguments as parameters, an argument doesn't turn into a SQL statement. What's dangerous is **when the function's body assembles dynamic SQL with that argument.**

```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本文に連結される＝注入は“関数の中”で起きる
```

The attack is the classic itself. Put the following in `q`.

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

The assembled SQL becomes this.

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

Concatenate a different table with `union`, and invalidate the tail with `--`. **It's irrelevant even if `documents` has perfect RLS.** The reason is in the next section.

---

## 5. SECURITY DEFINER maximizes the damage

Section 4's function was `security definer`. This is a setting that **executes the function with the privileges of the definer (owner),** and typically the owner is the table owner = a role that can cross RLS ([PostgreSQL: CREATE FUNCTION](https://www.postgresql.org/docs/current/sql-createfunction.html)). In other words —

- SQLi inside a `security invoker` (default) function → the damage stops within the range of the calling user's privileges
- SQLi inside a `security definer` function → it **jumps over RLS wholesale with the owner's privileges,** and all tables are read/written

Supabase official also clearly states the existence of routes that cross RLS, like `service_role` ([Supabase: Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security)). **The combination of dynamic SQL and `SECURITY DEFINER` is an amplifier that expands SQLi damage from "part of a tenant" to "the entire database."** So a function where these two overlap becomes the top-priority audit target.

The correct use of `SECURITY DEFINER` itself (countering schema hijacking by fixing `search_path`, least-privilege owner design, etc.) has many points, so it's carved out in [the safe design of SECURITY DEFINER functions and search_path](/blog/supabase-security-definer-function-search-path-guide). In this article, I thoroughly maintain only the principle "if you build dynamic SQL in a definer-privileged function, never string-concatenate the input."

---

## 6. Safe patterns — the four tools for when dynamic SQL is needed

Here's the main body of the countermeasures. When dynamic SQL is unavoidable, the correct tool differs per kind of input (**value / identifier / keyword**). Mixing them opens a hole.

### 6-1. Separate values into EXECUTE … USING parameters

The safest and most-should-use means. If you pass values with the `$1` placeholder and the `USING` clause, the input goes through no string concatenation at all and is treated as a bind variable.

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

### 6-2. Identifiers are format('%I') — but the real defense is the "allowlist"

`$1` **can only be used for values.** Since **identifiers** like table names and column names can't be parameterized, wrap them in `format()`'s `%I` (which internally calls `quote_ident`). `%I` quotes it safely as an identifier and prevents breaking the syntax.

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

Here let me **draw the line honestly.** What `%I` (`quote_ident`) prevents is only the **syntactic injection** of "escaping from the identifier and inserting another SQL." **It doesn't make the business judgment of "whether that identifier may be shown."** If the attacker specifies in `target_table` another existing table (e.g., `auth_secrets`), `%I` quotes and passes it without any warning. So the real defense for identifiers is **allowlist verification.**

```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;
$$;
```

The reason embedding `dir` with `%s` (uninspected string substitution) is safe is that **you passed the allowlist verification right before.** SQL keywords like `ASC`/`DESC` can be neither a value nor an identifier, and can only be made safe in the order allowlist → substitute only the verified value. You may use `%s` only for "a value whose validity you've already guaranteed yourself," like this.

### 6-3. Literals are %L (or quote_literal/quote_nullable)

In a scene where you absolutely must embed a value in a string, use `%L` (internally `quote_literal`). `%L` quotes it safely as a string literal and correctly converts `NULL` to a `NULL` literal too.

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

Let me also pin down the trap of using `quote_literal` bare. `quote_literal(NULL)` returns **SQL's NULL,** not the string `'NULL'`, so concatenating it makes broken SQL. For a value that can take NULL, use `quote_nullable`, or leave it to `format`'s `%L` as above — that's safe.

### 6-4. Don't use %s "for identifiers/keywords"

`format()`'s `%s` is a `sprintf`-like **uninspected string substitution.** Using `%s` for an identifier or keyword loses `%I`'s protection and allows identifier injection.

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

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

Let me organize the four tools in one sheet.

| Kind of input | Correct tool | Note |
|---|---|---|
| Value (VALUES, the comparison value of WHERE) | `$1` + `USING` | Top priority. Don't concatenate at all |
| A value to embed as a literal | `%L` / `quote_literal` | NULL is `%L` or `quote_nullable` |
| Identifier (table name, column name) | `%I` (+ allowlist) | `%I` protects only the syntax. Validity is by the allowlist |
| Keyword (ASC/DESC, etc.) | allowlist + `%s` after verification | can be neither a value nor an identifier |

---

## 7. Raw SQL on the app side — with a placeholder, or a tagged template

When you throw SQL directly from the app's server code outside a function (`postgres.js`, `node-postgres`, etc.), the principle is the same "don't concatenate."

```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]);
```

A library's tagged template (`` sql`...${value}...` ``) is safe because it automatically converts `${value}` into a bind variable. What's dangerous is only when you concatenate input into `sql.unsafe(...)`, which **explicitly removes** that safety device. The name `unsafe` isn't for show.

And the worst anti-pattern is exposing "a general-purpose function that executes arbitrary 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が通る。存在自体が脆弱性
```

This is, rather than "an injection vulnerability," "**turning injection itself into an API,**" and with `SECURITY DEFINER` the entire DB falls in one hit. Don't create it just because it's convenient.

---

## 8. Assembly of search filters — PostgREST filter injection in .or()

The last danger zone is the case of passing a **raw filter string** to `supabase-js`'s `.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}*`);
```

**Let me distinguish honestly.** This isn't strictly PostgreSQL-level raw SQLi. Since PostgREST parameterizes the final SQL, you can't directly inject a SQL statement like `drop table`. But because you can break the `.or()` filter **syntax** and tamper with the condition, the attacker can manipulate "which rows are returned." Combined with an RLS-weak table, the premise of authorization collapses. As a **logical injection,** it's a fully-fledged member of the injection class. If you pass a value to the structured API (`.eq`, `.ilike`, etc.), the need to assemble a filter string itself disappears.

---

## 9. Taint analysis — follow the reach from tainted input to a SQL sink

The danger zones up to here look disparate but share the same structure. **A client-manipulable input (source) reaches a process that assembles a SQL statement (sink) without being validated.** So you can follow it mechanically with data-flow analysis (taint analysis), not regex.

| Tainted input (source) | Dangerous sink | Why it's dangerous |
|---|---|---|
| rpc arguments / searchParams / request body | EXECUTE + string concatenation inside a function | input mixes into the SQL body (classic SQLi) |
| same as above | format's %s (identifier, keyword) | uninspected substitution = identifier injection |
| same as above | string concatenation of sql.unsafe / a raw query | app-layer raw SQL injection |
| same as above | the filter string of .or() / .filter() | PostgREST filter injection (logical injection) |

If you look at whether a **valid sanitizer** (`$1`/`USING`, `%I`+allowlist, `%L`, the structured API) is on the source→sink route, you can automatically pick up the dangerous flows. SSRF and path traversal also have the same skeleton of "source→sink," determinable by whether the input passes to a URL or path sink ([the SSRF-prevention guide](/blog/nextjs-ssrf-prevention-server-actions-route-handlers-guide) handles the same structure with a different sink).

> **Here let me draw the honest scope.** What taint analysis can mechanize is only "whether the tainted input reached a SQL sink without going through a sanitizer." **Whether the sanitizer is "valid" — whether the allowlist covers the correct set, whether the choice of identifier wrapped in `%I` is business-valid — can't be judged by a tool.** Data-flow analysis is intraprocedural in principle, and misses flows where a function calls another function, or where it assembles a string and passes it elsewhere. Detection only shows "it doesn't step on the common traps," and doesn't prove "it's safe." **Safe query design needs, in the end, human review** — this is the consistent stance of this article.

---

## 10. Detection — visualize source→SQL sink with npx

Once you've decided to plug it with design, continuously verify "is it plugged." The OSS Aegis I publish, in addition to the above taint analysis (tainted input→SQL sink), reads `supabase/migrations/**.sql` and surfaces **dangerous dynamic-SQL patterns** (string concatenation into `EXECUTE`, `format`'s `%s` identifier, `SECURITY DEFINER` + dynamic SQL, an `exec_sql`-like general-purpose function). It runs with no installation.

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

Using dynamic confirmation together raises the certainty. The reproducibility of SQLi is confirmed by giving input (delimiters, `union`, the comment `--`, etc.) **against an environment you own** and seeing whether an error or unexpected rows are returned. For testing viewpoints, OWASP's testing guide is systematic ([OWASP Web Security Testing Guide](https://owasp.org/www-project-web-security-testing-guide/)). The correlation of fixing first what matches between static analysis's "suspicion" and dynamic's "reproduction" is effective.

---

## 11. Pre-production checklist

Whether outsourced or AI-made, confirm at least this before going to production.

- [ ] In dynamic SQL inside a function, **values are separated into `EXECUTE … USING $1`** (not concatenated into the SQL body)
- [ ] **Identifiers are wrapped in `format`'s `%I`** and also **verified with an allowlist**
- [ ] **Literals are `%L`** (or `quote_literal`/`quote_nullable`). No bare concatenation
- [ ] You **don't use `format`'s `%s` for identifiers/keywords** (if you do, only after allowlist verification)
- [ ] You **don't unnecessarily make a statically-writable function dynamic with `EXECUTE`**
- [ ] `SECURITY DEFINER` is only on genuinely-needed functions. You fix `search_path`
- [ ] App-side raw SQL is a **placeholder (`$1`) or a tagged template.** You don't concatenate input into `sql.unsafe`
- [ ] You **don't expose a general-purpose function that executes arbitrary SQL (`exec_sql`, etc.)**
- [ ] Search passes values via the structured API like `.eq`/`.ilike`. You **don't concatenate user input into `.or()`**
- [ ] You **permanently station static analysis of tainted-input→SQL-sink in CI**

The questions effective from the client's viewpoint are three. **"Which functions use dynamic SQL (`EXECUTE`)?" "How do you handle input in a `SECURITY DEFINER` function?" "How do you assemble search?"** — a good developer can answer immediately.

---

## 12. How far yourself, and where audit begins

Finally, let me draw the line honestly.

**Detecting the reach of tainted-input→SQL-sink can be mechanically crushed by automation.** Concatenation into `EXECUTE`, `%s` identifier substitution, concatenation into `sql.unsafe`, concatenation of input into `.or()` — these have a stereotyped structure, so if you put static analysis into CI, a human doesn't need to watch every time. First visualizing the current state with [Aegis](/aegis) (free OSS, `npx @aegiskit/cli scan`) is the most cost-effective first step.

On the other hand, **"whether the sanitizer is valid" and "whether the dynamic-SQL design is correct" are the human domain.** Whether the allowlist is the correct set, whether there's a necessity to use `SECURITY DEFINER`, how far that function may cross RLS — these can only be judged by a human who understands your data model and business rules. **A product that asserts "put in this tool and SQLi is completely safe" is rather dangerous.** A tool helps detection but doesn't prove the correctness of query design.

That's exactly why a line is needed. If you need a design fix of functions including dynamic SQL, or a review of an existing Supabase app's RPC/RLS/authorization, I take it on with a [security audit](/aegis/audit). I myself, in the [lumber-distribution-industry DX project](/case-studies/lumber-industry-dx), designed and verified data-layer authorization including 221 APIs and PostgreSQL functions/RLS in actual operation. Building fast with AI is itself correct. **Firming up what you built fast, safely, without leaks** — building that mechanism is the main thread.

---

## Frequently asked questions (FAQ)

**Q. If I use supabase-js's `.eq()` or `.ilike()`, will SQLi not occur?**
A. It won't occur on that route. As long as you pass a **value** to the structured API, the input is treated as a parameter and doesn't affect the SQL statement's structure. It occurs only when you step outside the "safe layer," like concatenating a filter string into `.or()`, building dynamic SQL inside an RPC, or using `sql.unsafe`.

**Q. Does using `rpc()` increase the danger of SQLi?**
A. The `rpc()` call (argument passing) itself is safe, and arguments are bound as parameters. What's dangerous is when **the function's body** embeds that argument into dynamic SQL by string concatenation. The danger lies not in "using `rpc`" but in "the function's contents."

**Q. Is it safe if I use `format()`?**
A. It depends on the placeholder. Values with `%L` and identifiers with `%I` are on the safe side, but `%s` is an uninspected string substitution, so using it for identifiers/keywords allows injection. Furthermore, `%I` only prevents breaking the syntax and doesn't judge "whether that identifier may be shown." Protect identifiers in two stages: `%I` + an **allowlist.**

**Q. If I stretch RLS, won't SQLi be a problem?**
A. It won't not be. SQLi inside a `SECURITY DEFINER` function **jumps over RLS wholesale.** Conversely, even with `SECURITY INVOKER`, if you pull unintended rows by injection, the premise of authorization collapses. RLS is mandatory, but SQLi countermeasures (parameterization, allowlist) are needed both as a separate layer.

**Q. If I ask the AI to "write it securely," will dynamic SQL become safe?**
A. Don't expect too much. AI writes a "working RPC" the fastest, but concatenation into `EXECUTE` and misuse of `%s` don't surface in a demo. It becomes production quality only after you pass through verification gates (static analysis, tests, review).

---

## Summary: watch the moment you step "outside" the safe layer

Let me organize the key points.

- The reason "plain SQLi" is unlikely in Supabase is that PostgREST and the structured API **pass values as parameters.** But **outside the safe layer** — dynamic SQL inside functions, raw SQL, the assembly of search filters — it occurs normally.
- The biggest trap is **dynamic SQL inside a `SECURITY DEFINER` function (`EXECUTE` + concatenation).** When tainted input mixes into the SQL body, all tables are siphoned with the owner's privileges, crossing RLS. Even if `rpc()`'s argument passing itself is safe, the function's contents reintroduce injection.
- The safe pattern separates the tool by kind of input — **values with `$1`/`USING`, literals with `%L`, identifiers with `%I` + an allowlist, keywords with `%s` after the allowlist.** And **don't unnecessarily make a statically-writable process dynamic.**
- The reach of tainted-input→SQL-sink **can be mechanically detected with taint analysis.** But **the validity of the sanitizer and the correctness of query design can only be guaranteed by human review.** A "put it in and it's completely safe" product doesn't exist.

If you need a review of an existing Supabase app's RPC/dynamic SQL/RLS, or building a mechanism to incorporate injection detection into CI, please feel free to consult me.

---

## References

- [OWASP Top 10 (the major risks of web apps including Injection)](https://owasp.org/www-project-top-ten/)
- [PostgreSQL — CREATE FUNCTION (SECURITY DEFINER / INVOKER, function privileges)](https://www.postgresql.org/docs/current/sql-createfunction.html)
- [Supabase Docs — Row Level Security (definer privileges and service_role cross RLS)](https://supabase.com/docs/guides/database/postgres/row-level-security)
- [OWASP Web Security Testing Guide (testing viewpoints for injection)](https://owasp.org/www-project-web-security-testing-guide/)
