# Detecting Supabase RLS Misconfigurations — Surfacing Not-Enabled, Missing WITH CHECK, USING(true), and Over-Granted anon from Your Migrations

> Supabase RLS opens holes by 'thinking you enabled it.' A practical guide to surfacing and plugging the dangerous patterns — RLS not enabled, missing WITH CHECK, USING(true), over-granted anon, and SECURITY DEFINER without a fixed search_path — with static verification of supabase/migrations/**.sql.

- Published: 2026-06-28
- Author: 友田 陽大
- Tags: Supabase, RLS, PostgreSQL, セキュリティ, アーキテクチャ設計
- URL: https://tomodahinata.com/en/blog/supabase-rls-misconfiguration-detection-audit-guide
- Category: Application-layer security
- Pillar guide: https://tomodahinata.com/en/blog/nextjs-supabase-application-security-guide

## Key points

- Supabase auto-turns public-schema tables into APIs with PostgREST. A table without RLS enabled can be read in full by anyone with just the anon key shipped to the browser — 'just creating a table' becomes instant, full exposure
- The dangerous patterns are finite and mechanically detectable: ① RLS not enabled ② unconditional allows like USING(true) ③ missing WITH CHECK (can't read but can write / can rewrite the owner) ④ over-granting to the anon role ⑤ SECURITY DEFINER functions without a fixed search_path (RLS bypass / privilege escalation)
- USING and WITH CHECK are separate axes. USING is 'which existing rows you touch (the target of reads / updates),' WITH CHECK is 'whether the value of the new row you write is allowed.' A state of perfect reads but defenseless writes normally happens
- These can be surfaced by statically reading supabase/migrations/**.sql. The OSS `npx @aegiskit/cli scan` (MIT) does this RLS verification. The design goal is that correct production RLS produces 0 detections
- But 'detection' and 'correct design' are different things. The design of the ownership model and tenant boundaries is human judgment, and the tool only sees the 'shape' of policies. 0 detections is 'you didn't step on a common trap,' not proof that 'authorization is correct' — this is the audit domain

---

Let me state the conclusion first. **Supabase's row-level security (RLS) opens holes by "thinking you configured it."** And the ways those holes open don't have infinite variations; they converge to a **finite, fixed set of patterns** — forgetting to enable RLS, unconditional allows like `USING (true)`, missing `WITH CHECK`, over-granting permissions to the `anon` role, and `SECURITY DEFINER` functions that don't fix `search_path`. These five.

And what's important is that **these can be mechanically surfaced just by statically reading `supabase/migrations/**.sql`.** When you hear "vulnerability scanning," you might imagine "actually attacking and trying," but most RLS misconfigurations can be judged "this is dangerous" at the point of syntactically parsing the SQL DDL (the definitions of tables, policies, and functions). This article shows the catalog of those dangerous patterns, why they're mass-produced, and how to auto-detect them from migrations, with vulnerable→fixed real SQL.

But at the end, let me draw an honest line. **Even if "detection" can be mechanized, "correct RLS design" can't.** Who owns what, and where you draw the tenant boundary — these are design decisions specific to your business domain, and the human audit domain. What a tool can say goes only as far as "you didn't step on a common trap," not "your authorization is correct." Not confusing these is the thing I most want to convey in this article.

Note this article is part of a series on authorization. The overall security picture of Next.js × Supabase is in the [comprehensive guide](/blog/nextjs-supabase-application-security-guide), key handling is in [anon-key / service_role-key exposure](/blog/supabase-anon-key-service-role-key-exposure-guide), and reading others' data by rewriting an ID key — [IDOR/BOLA](/blog/nextjs-supabase-idor-broken-authorization-rls-detection-guide) — is each split out. This article narrows to "misconfigurations of the RLS policies themselves."

---

## 1. Premise: in Supabase, "create a table" is synonymous with "publish an API"

To understand why RLS misconfiguration is fatal, you need to grasp just one thing about Supabase's architecture.

**Supabase automatically publishes `public`-schema tables as a REST API via PostgREST.** The instant you create one table, an endpoint `https://<project>.supabase.co/rest/v1/<table>` sprouts. And this API can be hit with the **anon key** shipped to the browser. The anon key is not secret information but a public key embedded in the client ([Supabase: API keys](https://supabase.com/docs/guides/api/api-keys)).

That is, the causation is like this.

```text
CREATE TABLE しただけ        →  REST API が自動で生える
RLS を有効化していない        →  anon キーでそのAPIが素通り
anon キーは公開情報           →  実質「誰でも全件読める」
```

What kicks in here is PostgreSQL's spec. **RLS is disabled unless you explicitly enable it per table.** `CREATE TABLE` doesn't enable RLS. Only after separately running `ALTER TABLE ... ENABLE ROW LEVEL SECURITY` does row control begin ([PostgreSQL: Row Security Policies](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)). Create it in the Supabase dashboard's Table Editor and RLS is enabled by default, but **write `create table` in an SQL migration and it isn't enabled** — this asymmetry is the biggest supplier of misconfigurations.

The Supabase official docs also state clearly that "if you build on Postgres, you should enable RLS on all tables to protect your data" ([Supabase: Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security)). Conversely, enabling is not "something done for you" but "something you write."

Under this premise, let me break down the dangerous patterns one by one. Each pattern is shown in the order "what happens → vulnerable SQL → fixed SQL."

---

## 2. Dangerous pattern ①: RLS not enabled (forgetting `ENABLE ROW LEVEL SECURITY`)

The most common and most fatal is this. Publishing a table while leaving RLS unenabled.

### What happens

As in Section 1, a table without RLS can be read in full with the anon key. With a personal-information table like `profiles` in this state, names, emails, phone numbers, and payment customer IDs leak externally with a single `curl`.

### Vulnerable SQL

```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>"
```

A migration of just `create table` is, as SQL, perfectly correct. The types, NOT NULL, and foreign key are all appropriate. It's a typical example showing that **"working" and "being safe" are completely separate problems.**

### Fixed SQL

It's important that the fix is two stages. The order is **enable (make it fail-secure) → explicitly allow only the necessary access.**

```sql
-- ステップ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 );
```

There are three points to grasp.

1. **Enabling RLS is fail-secure.** Enable RLS with no policy at all, and the default is "deny all" ([PostgreSQL: Row Security Policies](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)). Do "just `enable` for now" and you at least stop defenseless full exposure. The first thing a detection tool should pick up is this "table where `enable` doesn't exist."
2. **Wrap `auth.uid()` with `(select auth.uid())`.** It's the way Supabase officially recommends — it doesn't re-evaluate the function per row but caches it in the initial plan, so performance changes by orders of magnitude on a large table ([Supabase: Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security)). You avoid the trap of "works correctly but slow."
3. **The table owner bypasses RLS.** In PostgreSQL, the table owner (in Supabase, mostly `postgres`) is by default exempt from RLS. Unless you attach `ALTER TABLE ... FORCE ROW LEVEL SECURITY`, access via the owner doesn't undergo row control ([PostgreSQL: Row Security Policies](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)). This directly connects to the pitfall of the `SECURITY DEFINER` function (pattern ⑤) described later.

---

## 3. Dangerous pattern ②: `USING (true)` / an unconditional policy

You enabled RLS and wrote a policy too. But that condition is "always true" — this is pattern ②.

### What happens

`using (true)` means "make all rows visible." In the reassurance of having enabled RLS, you effectively revert "deny all" to "allow all." Look only at the log of `alter table ... enable row level security` and it looks like you're protecting it, while the inside passes through — the case where detection has the most value.

### Vulnerable SQL

```sql
alter table public.documents enable row level security;

-- 危険：条件が true ＝全行を無条件に許可（RLSを有効化した意味が消える）
create policy "anyone can read documents"
on public.documents
for select
using ( true );
```

What's also easily overlooked is **omitting the `TO` clause.** A policy without `TO` applies to `PUBLIC`, that is, all roles including `anon` ([PostgreSQL: CREATE POLICY](https://www.postgresql.org/docs/current/sql-createpolicy.html)). The above policy doesn't even have `to authenticated`, so **even a not-logged-in anon can read all `documents`.** The combo of `using (true)` and omitting `TO` is synonymous with full exposure of a sensitive table.

### Fixed SQL

```sql
-- 所有者の行だけを、ログイン済みユーザーに対して許可する
create policy "owners read their documents"
on public.documents
for select
to authenticated
using ( (select auth.uid()) = owner_id );
```

Here, let me draw an honest line. **`using (true)` is not necessarily a bug.** For a public table of blog articles, or a table like master data where "everyone really may read it," `to anon using (true)` is correct design. So the correct behavior for a detection tool is to emit `using (true)` **not as "instantly vulnerable" but as a warning that prompts a human to confirm: "an unconditional allow. Is this really the publication intent?"** What the machine can judge goes only as far as the syntactic fact that "it's unconditional," and "is this data OK to publish" is domain knowledge. I'll emphasize this dividing line again later in this article.

---

## 4. Dangerous pattern ③: missing `WITH CHECK` — creating "can't read but can write"

This is the most misunderstood point in RLS. **Reading is perfectly locked, but writing is defenseless** — this state normally occurs. The cause is confusing the roles of `USING` and `WITH CHECK`.

### `USING` and `WITH CHECK` are separate axes

A PostgreSQL policy has two kinds of expressions ([PostgreSQL: CREATE POLICY](https://www.postgresql.org/docs/current/sql-createpolicy.html)).

| Expression | What it inspects | Commands it affects |
|---|---|---|
| `USING` | **Existing rows.** Which rows are visible / can be the target of update/delete | SELECT / UPDATE / DELETE |
| `WITH CHECK` | **The value of a new row.** Whether the row to write at INSERT/UPDATE is allowed | INSERT / UPDATE |

What's decisively important is that **INSERT has no `USING`.** A new row has no "existing row," so the only thing that can constrain INSERT is `WITH CHECK`. Therefore, an INSERT policy where you forgot `WITH CHECK` / set it to `true` **passes through a write with any value.**

### What happens

There are two concrete harms.

1. **Impersonation insert**: a user INSERTs with `user_id` set to someone else's ID, planting data in someone else's name / contaminating another tenant.
2. **Ownership rewrite**: with UPDATE, rewriting your own row's `owner_id` to someone else, or escalating `role` to `admin`.

### Vulnerable SQL

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

The terror of this configuration is that **because the SELECT policy is perfect, you get the illusion that "RLS is in effect."** In a read test, others' data isn't visible, so it passes review. The hole is open on the write side.

### Fixed SQL

In write-system policies, always require in `WITH CHECK` that **the owner of the new row is yourself.**

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

### A detection-side note: omitting `WITH CHECK` is not "always a bug"

To be accurate, if you **omit** `WITH CHECK` in a `FOR ALL` or `FOR UPDATE` policy, PostgreSQL reuses the `USING` expression for `WITH CHECK` too ([PostgreSQL: CREATE POLICY](https://www.postgresql.org/docs/current/sql-createpolicy.html)). That is, the following is safe.

```sql
-- USING がスコープされ、WITH CHECK 省略 → USING が流用されるので書き込みも守られる
create policy "manage own"
on public.documents
for all
to authenticated
using ( (select auth.uid()) = owner_id );
```

Therefore, what a detection tool should really pick up is not "omission" but **(a) `FOR INSERT` with no `WITH CHECK` / `true`, (b) explicitly stating `WITH CHECK ( true )` for any command, and (c) the `USING` side being `using (true)` in the first place, so reuse is meaningless** — these three. Mechanically alarm "omission = instant bug" and false positives (noise) increase, and the tool stops being trusted. The quality of RLS verification is determined by how accurately you can model these kinds of behavioral differences.

---

## 5. Dangerous pattern ④: over-GRANT/policy to the `anon` role

In Supabase, two database roles are used depending on the JWT: the not-logged-in **`anon`** and the logged-in **`authenticated`**. PostgREST runs queries as these roles, and RLS is evaluated against that role.

### What happens

Think of `anon` as "the entire internet." Hand `anon` access to a sensitive table and it leaks without even needing authentication. There are two routes to over-granting.

1. **Target `anon` in a policy** (`to anon`, or including it in `PUBLIC` by omitting `TO`)
2. **GRANT table permissions to `anon`** (`grant select on ... to anon`)

### Vulnerable SQL

```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 系に書き換えた瞬間に全公開へ反転する地雷になる
```

In addition, also look at the permission grant on the table itself.

```sql
-- 危険：anon に直接 SELECT 権限を与える（RLS が無効だと即・全公開）
grant select on public.orders to anon;
```

### Fixed SQL

In principle, limit sensitive tables to an `authenticated` starting point, and narrow exposure to `anon` to only "data that's really OK to publish."

```sql
-- ポリシーは 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;
```

From the detection standpoint, surface "policies for `to anon` / omitting `TO`" and "`GRANT`s to `anon`" against sensitive tables, and **warn about ones whose publication intent isn't explicit.** The anon key itself is "a key OK to publish on the premise of being used together with RLS" ([Supabase: API keys](https://supabase.com/docs/guides/api/api-keys)), but that's **only if RLS is correctly in effect.** The safe placement of the key itself is detailed in [anon/service_role-key exposure](/blog/supabase-anon-key-service-role-key-exposure-guide).

---

## 6. Dangerous pattern ⑤: `SECURITY DEFINER` functions without a fixed `search_path`

The last is a pattern that opens a hole from "outside" RLS. It lurks in PostgreSQL functions published as RPC (remote functions).

### What happens

A function with `SECURITY DEFINER` attached **runs with the function owner's permissions, not the caller's** ([PostgreSQL: CREATE FUNCTION](https://www.postgresql.org/docs/current/sql-createfunction.html)). In Supabase, the function owner is mostly `postgres` — that is, **the table owner, the role that bypasses RLS** (the 3rd point of Section 2). Therefore, queries inside a `SECURITY DEFINER` function **completely ignore RLS.** Publish this to anon/authenticated via PostgREST without an ownership check inside the function, and however much RLS you stretch, it's passed through.

Further, if you don't fix `search_path`, it's another level of danger. The PostgreSQL official docs clearly warn that "a `SECURITY DEFINER` function can be hijacked if an untrusted user can create objects in a schema searched earlier, so fix `search_path` to a safe value" ([PostgreSQL: CREATE FUNCTION](https://www.postgresql.org/docs/current/sql-createfunction.html)). An unqualified name (a reference without a schema, like `profiles`) gets resolved to a different object the attacker prepared and runs with owner permissions — a breeding ground for privilege escalation.

### Vulnerable SQL

```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 を渡せば、他人のアカウントを削除できる
```

This function, even against a `profiles` with RLS perfectly stretched, **can delete all rows because it runs with owner permissions.** RLS is irrelevant.

### Fixed SQL

If you use `SECURITY DEFINER`, thoroughly apply 4 points: (1) fix `search_path`, (2) fully qualify objects, (3) enforce the caller's identity inside the function, and (4) don't hand it to `anon`.

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

The detection standpoint is simple. **List all functions that have `security definer` but no `set search_path`**, and additionally warn about `grant execute` to `anon`. The former can be reliably picked up by syntax alone (the presence of `search_path` is machine-judgable). The latter, "whether identity is enforced inside the function," requires reading the function body's logic, and this is half the human domain.

---

## 7. Why they're mass-produced in AI-generated code and rushed development

The 5 patterns up to here can be avoided if you know them. Why are they mass-produced nonetheless? The reason is structural.

**RLS misconfigurations absolutely don't surface in a demo.** As long as you're touching it with one account of your own, the screen looks the same whether `using (true)` or with an ownership check. Even without `WITH CHECK`, updating your own row works. **The "worked = correct" trap** is especially deep in authorization. The bug becomes visible only "when someone else's account exists and you cross that boundary," and that mostly doesn't happen during development.

AI code generation amplifies this tendency. AI writes code that achieves the prompt's "what you want to do (= working in a demo)" by the shortest path. "Don't show others' data" and "can't impersonate others" are outside the happy path unless explicitly required, and they're either not output, or tend to be a "just-passes" form like `using (true)`.

This isn't abstraction but is recorded as a real incident. **[CVE-2025-48757](https://nvd.nist.gov/vuln/detail/CVE-2025-48757)**, registered in 2025, is the typical case. According to the NVD description, due to **insufficient Row-Level Security policies** in the AI app-generation platform Lovable (through 2025-04-15), a remote **unauthenticated** attacker could read and write arbitrary DB tables of generated sites. The classification is **CWE-863 (Incorrect Authorization)**, and the CVSS base score is **9.3 CRITICAL**. It's evidence that this article's pattern ① (RLS not enabled) and ④ (exposure to anon) actually happened at scale.

And the typical harm this misconfiguration brings is an authorization failure — **[API1:2023 Broken Object Level Authorization (BOLA)](https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/)**, which OWASP ranks #1 in API risks. RLS is a primary line of defense to prevent this BOLA "at the DB layer," and its configuration crumbling directly connects to the most frequent API risk.

In short, **an RLS misconfiguration is not "a rare, advanced bug" but a "normal accident" structurally born in modern development that prioritizes speed.** That's exactly why, in addition to human review, there's value in building mechanical detection into the pipeline.

---

## 8. Automating detection: the idea of static verification of migrations

What the 5 patterns have in common is that **they all leave a trace in the DDL of `supabase/migrations/**.sql`.** You can judge by reading the SQL without actually attacking. This is "static verification of migrations."

### A simple grep is insufficient

What to be careful of is that **a `grep` on a single file misjudges.** Enabling RLS can be done in a subsequent migration separate from table creation. Judge `enable row level security` as "dangerous because it's not in some file," and you false-positive cases enabled in another file. Correctly, you need to **fold all migrations chronologically and reconstruct each table's "final state."**

### A conceptual model

Let me show the idea in pseudo-code. Fold migrations, build a state model per table, and match against the dangerous patterns.

```ts
// 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;
}
```

This is just the skeleton. In actual detection, consider the behavioral difference described in Section 4 — "`FOR ALL`/`FOR UPDATE` reuses `USING` when `WITH CHECK` is omitted" — to reduce false positives, and also analyze the presence of `search_path` in `SECURITY DEFINER` functions and `GRANT`s to `anon`.

### Running it with OSS

The OSS that can run this detection with no configuration is **Aegis** (MIT license). It analyzes `supabase/migrations` and matches against the above catalog (①–⑤).

```bash
# インストール不要・設定不要。プロジェクト直下で実行する
npx @aegiskit/cli scan
```

I won't overclaim. This is "heuristic static analysis against DDL," nothing more, nothing less. I'll draw the line of the honest scope in Section 11 later. RLS design itself — for example [production multi-tenancy patterns](/blog/supabase-rls-production-multi-tenancy-patterns), [testing with pgTAP](/blog/supabase-rls-testing-pgtap-policy-regression-guide) to prevent policy regressions, and [on-the-ground verification of cross-tenant crossing](/blog/supabase-multi-tenant-cross-tenant-leak-verification-guide) — are handled in separate articles.

---

## 9. Even if you can "detect," "correct design" is a separate problem

This is the most important section of this article. Up to the previous section, I said "dangerous patterns can be machine-detected." So, if detections become 0, is the RLS "correct"? **No.**

What a detection tool sees is the **"shape (syntax, structure)"** of policies and functions. Not the **"meaning"** of your business rules and data model. Concretely, there are things the tool fundamentally can't know.

- **The ownership model**: is the column representing this table's "owner" `owner_id`, `user_id`, or `created_by`? Is the column to match against `auth.uid()` really that column?
- **Tenant boundaries**: in multi-tenancy, are you isolating by `user_id` when rows should be isolated by `organization_id`? Are rows all members of an organization should see locked to only the creator (or vice versa)?
- **Business rules**: is state-dependent visibility like "drafts are for the author only, published is for everyone" correctly reflected in the policy?
- **Role design**: what may `admin` cross? Is that "crossing" an intended permission, or a bug?

It normally happens that a syntactically perfect policy `(select auth.uid()) = user_id` is **domain-wise wrong.** For example, lock data that should originally be shared at the organization level to the individual level, and detection is 0 but it doesn't meet the requirements (or a hole opens by being "worked around" with `using (true)` elsewhere).

So **the design goal of correct production RLS is "detections become 0"** — this is a necessary condition, not a sufficient one. 0 detections is proof of "you didn't step on a common trap," not proof that "authorization is correct." The latter can be said only once humans design, review, and "confirm" the ownership model and tenant boundaries with cross-tenant-crossing verification and pgTAP regression tests.

In a B2B SaaS where tenant-boundary design is the business's lifeline — for example a multi-tenant platform like the [lumber-distribution DX case](/case-studies/lumber-industry-dx) I was involved in — the very drawing of the line of "who owns what of which tenant" was the main body of authorization design. A tool is **a tool to verify that line fast and without gaps**, and can't proxy the drawing of the line itself.

---

## 10. A pre-production RLS checklist

Whether outsourced or AI-generated, before going to production, confirm at least these. All can be judged by reading `supabase/migrations/**.sql`.

| Viewpoint | What to confirm | Danger sign |
|---|---|---|
| **RLS enablement** | Do all `public` tables have `enable row level security` | Only `create table`, no `enable` |
| **Default deny** | Does an enabled table have the necessary policies in place | Enabled but with 0 policies, breaking functionality, and "worked around" with `using(true)` |
| **Unconditional allow** | Is the publication intent of `using (true)` / `with check (true)` clear | An unconditional policy on a sensitive table |
| **WITH CHECK** | Do INSERT/UPDATE inspect the new row's owner | `for insert` with no `with check` / `true` |
| **TO clause** | Does the policy make the target role explicit with `to authenticated` etc. | Omitting `TO` opens it up to `anon` |
| **anon permissions** | No `grant ... to anon` on sensitive tables | A personal-info table GRANTed to anon |
| **SECURITY DEFINER** | Is `set search_path` fixed and identity enforced inside the function | `search_path` not fixed / `execute` granted to `anon` |
| **Verification automation** | Are scans / regression tests built into CI | No basis other than "worked on my machine" |

The two most effective questions from the buyer's viewpoint are: **"If I hit it not logged in (anon), which tables are visible?" "How many `security definer` functions are there, and does each one fix search_path?"** — if you can't answer clearly and immediately, there's a high chance RLS verification isn't built into the design.

---

## 11. The range you do yourself vs. the range you leave to an audit (honestly)

Finally, let me show the realistic dividing line. What to do yourself, and from where to leave to a specialist.

**The range you should do yourself (= OSS covers it enough):**

- **Machine detection** of patterns ①–⑤. Put `npx @aegiskit/cli scan` into CI and run it automatically on every migration. This is free and can be done from today.
- The syntax-level confirmation of the checklist. The presence of `enable`, the presence of `with check`, and the presence of `search_path` can be picked up by a human reading the SQL too.
- Basic regression testing with pgTAP. Write one for "others' rows aren't visible / writable."

**The range you should leave to an audit (= human design judgment is needed):**

- **A design review of the ownership model and tenant boundaries.** Whether the column matched against `auth.uid()` is correct, whether organization-level sharing matches the requirements — this is judgment after understanding your business.
- **The "beyond" of 0 detections.** Even if the detection tool is clean, whether there's a domain-wise hole is a separate problem. Threat modeling and on-the-ground verification of actually impersonating another tenant and hitting it are needed.
- **An inventory of the existing app's whole authorization.** A cross-cutting review including not just RLS but the service_role route, RPC, Storage, and external join destinations.

Along this dividing line, the OSS [Aegis](/aegis) handles "detection," and the [security audit](/aegis/audit) handles "actually plugging it in design and implementation." The former is free, and the latter is provided as a spot diagnosis or a standard audit. **I'll never, ever say "safe because I put in a tool"** — that complacency is exactly what produces the worst outcome. A tool **complements human judgment, not replaces it.** Mechanically crush the most frequent traps so humans can concentrate on the truly hard design judgments. That's the correct way to use it.

---

## Frequently asked questions (FAQ)

**Q. If I enable RLS on all tables, can I prevent misconfigurations?**
A. You can prevent pattern ①, but not ②–⑤. Passing through with `using (true)`, defenseless writes for lack of `WITH CHECK`, jumping over RLS with a `SECURITY DEFINER` function — enabling is the starting point, not the goal.

**Q. Is `using (true)` always a bug?**
A. No. For data that's really OK to publish (public articles, master data, etc.), it's correct design. So detection should be treated as a confirmation warning of "an unconditional allow. Is this the publication intent?", and the machine declaring "instant bug" is wrong. Whether it's the publication intent is domain knowledge.

**Q. If I write only `USING`, are writes protected too?**
A. It depends on the command. `FOR ALL`/`FOR UPDATE` reuse `USING` when `WITH CHECK` is omitted, so they're protected, but **`FOR INSERT` has no `USING`**, so without `WITH CHECK` it's uninspected. Think of reads and writes as separate axes.

**Q. Should I not use `SECURITY DEFINER` functions?**
A. There are legitimate uses (aggregation or admin processing that crosses RLS, etc.). The iron rule is to fix `set search_path`, fully qualify objects, enforce identity inside the function, and not hand `execute` to `anon` ([PostgreSQL: CREATE FUNCTION](https://www.postgresql.org/docs/current/sql-createfunction.html)). Don't publish a function that can't keep these.

**Q. If detections are 0, can I say RLS is correct?**
A. You can't. 0 detections is proof of "you didn't step on a common trap," not proof that "authorization is correct." Whether the ownership model and tenant boundaries match the requirements needs to be confirmed by human design judgment and on-the-ground verification. As in Section 9, 0 is a necessary condition, not a sufficient one.

---

## Summary: the dangerous patterns are finite, detection can be mechanized. Humans do the design

Let me organize the key points.

- Supabase auto-turns `public` tables into APIs. **RLS not enabled means full exposure with "just creating a table,"** and this is what actually happened in CVE-2025-48757.
- RLS misconfigurations aren't infinite; they converge to **5 finite patterns** — ① not enabled ② unconditional allows like `using (true)` ③ missing `WITH CHECK` ④ over-granting to `anon` ⑤ `SECURITY DEFINER` without a fixed `search_path`.
- `USING` (existing rows) and `WITH CHECK` (the value of a new row) are **separate axes.** A state of perfect reads but defenseless writes normally happens.
- These can be **mechanically surfaced** by statically verifying `supabase/migrations/**.sql` folded chronologically. The OSS `npx @aegiskit/cli scan` (MIT) does this.
- But **"detection" and "correct design" are different things.** The design of the ownership model and tenant boundaries is human judgment, and 0 detections is "you didn't step on a trap," not proof that "authorization is correct."

Building fast with AI itself is correct. The problem is that there's **no mechanism to verify, without gaps, the authorization of what you built fast.** Machine detection of the dangerous patterns can start from today with the free OSS Aegis (`npx @aegiskit/cli scan`), and what comes after — a design review delving into the ownership model and tenant boundaries, and an RLS inventory of an existing Supabase app — I help with via the security audit touched on in the previous section. Feel free to consult me.

---

## References

- [Supabase Docs — Row Level Security (you should enable RLS on all tables / service_role bypasses RLS)](https://supabase.com/docs/guides/database/postgres/row-level-security)
- [Supabase Docs — API keys (the anon key is a public key, premised on being used with RLS)](https://supabase.com/docs/guides/api/api-keys)
- [PostgreSQL — Row Security Policies (RLS is disabled by default / enabled per table / the owner bypasses)](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)
- [PostgreSQL — CREATE POLICY (the difference between USING and WITH CHECK / omitting TO is PUBLIC)](https://www.postgresql.org/docs/current/sql-createpolicy.html)
- [PostgreSQL — CREATE FUNCTION (the warning about SECURITY DEFINER and fixing search_path)](https://www.postgresql.org/docs/current/sql-createfunction.html)
- [NVD — CVE-2025-48757 (unauthenticated read/write via insufficient RLS, CWE-863, CVSS 9.3)](https://nvd.nist.gov/vuln/detail/CVE-2025-48757)
- [OWASP API Security Top 10 — API1:2023 Broken Object Level Authorization](https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/)
