# Can you trust AI-generated Supabase RLS policies? Lessons from 1,000 apps measured, and how to spot the failures

> Supabase RLS generated by Claude Code or Cursor 'works,' but that doesn't mean it 'is authorization.' USING(true), authenticated-for-all, missing WITH CHECK, RLS never enabled, an unpinned search_path on SECURITY DEFINER — this guide catalogs the boilerplate mistakes AI emits in real SQL, pairs them with a measured lower bound of ~9.2% across 1,000 public apps, and lays out a verification workflow to catch them with a paste-only checker and a repo scan.

- Published: 2026-07-07
- Author: 友田 陽大
- Tags: Supabase, RLS, セキュリティ, バイブコーディング, AI駆動開発, PostgreSQL, Next.js
- URL: https://tomodahinata.com/en/blog/supabase-rls-ai-generated-policy-review-guide
- Category: Vibe coding: shipping AI-generated code safely
- Pillar guide: https://tomodahinata.com/en/blog/vibe-coding-what-is-tools-risks-production-guide

## Key points

- AI-generated RLS failures aren't infinite variations — they collapse into a handful of patterns: wide-open USING(true), authenticated-only allow via auth.role()='authenticated', missing WITH CHECK, RLS never enabled, an unpinned search_path on SECURITY DEFINER. So the answer isn't 'trust it / don't' — it's to mechanically enumerate them and confirm each one.
- These get mass-produced not from carelessness but from structure: literal implementation of the prompt ('a logged-in user can read' → auth.role()='authenticated'), and the fact that a developer uses only their own single account, so nothing surfaces in the demo. Smarter models don't change that structure.
- But 'looks like a pattern' ≠ 'vulnerable.' USING(true) is legitimate for a shared master table; a service_role gate and a select auth.uid() wrap are correct forms. Beat on AI output mechanically and you tend to false-positive these confusing correct cases — the numbers aren't trustworthy until you grind them down against real data.
- In a primary survey of 1,000 public Supabase apps, ~9.2% of the apps with RLS had a policy that 'authenticates but doesn't scope rows to the owner' (a lower bound, since it's a public-repo sample; owner-non-scoped ≠ immediately vulnerable — it needs confirmation). This isn't a special case; it's AI's standard output tendency.
- Embed verification as a single gate inside the generation loop: a paste-only browser RLS checker → a whole-repo npx @aegiskit/cli scan → pgTAP to pin regressions. Tools only mechanize the 'shape' of a predicate; whether the sharing intent is correct and whether tenant isolation is sound stay with human design judgment — the audit.

---

Let me lead with the conclusion. **AI-generated Supabase RLS policies "work" — but that doesn't mean they "are authorization." And the tricky part is that the failures aren't infinite variations; they collapse into a handful of patterns.** Wide-open `USING (true)`, authenticated-only allow via `auth.role() = 'authenticated'`, a missing `WITH CHECK`, a forgotten `ENABLE ROW LEVEL SECURITY`, an unpinned `search_path` on `SECURITY DEFINER` — as someone who ships with Claude Code every day, the "boilerplate mistakes" I've watched AI make over and over when asked to write RLS number so few you can count them on one hand. So the answer isn't "trust the AI / don't" — it's to **mechanically enumerate the things you already know will slip in the same place, and confirm each one.**

I'm not hostile to AI. If anything, generation speed is my biggest weapon. The reason I could ship — solo — a B2B SaaS that won a METI Minister's Award and a payment platform with zero double-charges in production is that I left the implementation to AI and concentrated on design and verification myself. But what turns that speed into production quality is the **verification gate** placed behind the generation. This article covers, in one pass: (1) why AI's RLS slips in the same place, (2) a real-SQL catalog of those boilerplate mistakes, (3) the flip side — forms that are "correct yet confusing," (4) measurements showing this isn't a special case, and (5) how to embed verification into the generation loop. Deep dives on each individual pattern are left to the existing canonical RLS articles — I won't re-explain all of it here.

For the buyer's-eye big picture of how to diagnose AI-generated code in general and harden it for production, see the [guide to assessing AI-generated code vulnerabilities](/blog/ai-generated-code-vulnerability-assessment-vibe-coding-security-guide) and the [vibe-coding production-hardening guide](/blog/vibe-coding-ai-generated-code-production-hardening-guide). This article is an implementer-focused deep dive narrowed to the part that leaks most easily of all: the "RLS policy predicate."

---

## 1. Why AI's RLS slips in the same place

AI mass-produces the same mistake not because of an attention problem. It's a **structural problem.** That's why crushing it with machinery, not vigilance, is what works.

**Structure 1: literal implementation of the prompt.** Ask "let a logged-in user read the notes" and AI — like a person — plainly writes "is the user logged in?" as the condition. `auth.role() = 'authenticated'` is that sentence turned almost verbatim into SQL. The unspoken requirement "only *their own* rows" never appears in the output unless you state it explicitly. Authentication (who are you) and authorization (is this row yours) are continuous in everyday language, but they're different things as SQL predicates. This boundary itself is detailed with real SQL in [the right way to fix RLS that "authenticates but doesn't authorize"](/blog/supabase-rls-authenticated-vs-authorized-owner-scope-guide), so I won't repeat it here.

**Structure 2: it never surfaces in the demo.** During development you use only your own single account. Since you've created only your own notes, even if all rows come back it just looks like "I can see all my notes" — it looks normal. Because **no one runs the operation of hitting someone else's ID from a second account,** the tests stay green and go to production. An RLS hole only becomes visible once you set up an "adversarial second person." AI won't set up that second person on its own.

**Structure 3: it doesn't change as models get smarter.** The hope that "a smarter model would write it safely" gets betrayed. Research finds the security score of generated code stays roughly flat across model generations. AI is extremely strong at generating "code that works," but it isn't the party that guarantees "a structure that won't break." So rather than betting on smartness, verifying mechanically with [CI quality gates](/blog/ai-driven-development-quality-gates-ci-type-safety-test-security) is the only reproducible solution.

This picture — where authorization, a *vertical risk*, gets overlooked by AI and humans alike under "it worked, so it's OK" — is exactly **A01:2021 Broken Access Control** ([OWASP](https://owasp.org/Top10/A01_2021-Broken_Access_Control/)), the immovable #1 of the OWASP Top 10. RLS is where you write this authorization *on the database side,* and it's also the place AI slips most easily.

---

## 2. A catalog of the boilerplate RLS mistakes AI emits

This is the heart of the article. I'll lay out, in real SQL, the RLS failures AI (Claude Code / Cursor / v0 / Lovable / Bolt, etc.) tends to generate. First, an overview.

| # | Boilerplate mistake | Why AI writes it that way | The one move to spot it |
|---|---|---|---|
| 2.1 | Wide-open `USING (true)` | Takes "get it working first" literally | Wide open despite an ownership column? |
| 2.2 | `auth.role() = 'authenticated'` | Literal translation of "a logged-in user can read" | Does the predicate reference `user_id`? |
| 2.3 | Missing `WITH CHECK` | Thinks only of SELECT and forgets writes | Is there a paired `WITH CHECK` on INSERT/UPDATE? |
| 2.4 | RLS never enabled / service_role misuse | Satisfied by `CREATE POLICY`, forgets `ENABLE` | Is there `ENABLE ROW LEVEL SECURITY`? Where's the key? |
| 2.5 | Unpinned `search_path` on `SECURITY DEFINER` | Creates the function but doesn't know the privilege-escalation surface | Does the definition have `set search_path`? |

### 2.1 `USING (true)` — the wide-open door

The most direct failure. Implement "just make it readable for now" literally and you get this.

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

-- 危険：所有列があるのに、全員に全行を開いている
create policy "read invoices" on public.invoices
  for select to authenticated
  using (true);
```

`USING (true)` is true for every row — it never references `user_id` on `invoices` and returns every invoice to every logged-in user. But as I wrote in the FAQ up top, **`USING (true)` is legitimate for a shared master table (a country list, categories, public articles).** The only dangerous case is the combination "wide open despite an ownership column." In Aegis's terms, this is a **vertical risk**: "if the table is *intended to be public,* `USING (true)` is fine; if it has an ownership column, confirm it" — a machine can warn on the "shape" but can't judge the "intent."

### 2.2 `auth.role() = 'authenticated'` — authenticates but doesn't authorize

The most frequent pattern — a supposedly slightly smarter `USING (true)`.

```sql
-- 危険：ログイン済みかしか見ていない（＝全ログインユーザーに全行）
create policy "read notes" on public.notes
  for select to authenticated
  using (auth.role() = 'authenticated');
```

`auth.role()` is always `'authenticated'` while logged in. `auth.uid() is not null` is the same — merely proof that a session exists, never once looking at `user_id`. The correct form binds to the owner.

```sql
-- 安全：行を呼び出し元の所有に束縛
create policy "read notes" on public.notes
  for select to authenticated
  using ((select auth.uid()) = user_id);
```

Wrapping `auth.uid()` in `(select auth.uid())` is the Supabase-recommended performance technique: it evaluates the function once in the initial plan rather than re-evaluating it per row ([Supabase Docs](https://supabase.com/docs/guides/database/postgres/row-level-security)). Why this leaks every row, how to distinguish it from a shared table, and reproducing it with `curl` are all left to [the canonical authentication-vs-authorization article](/blog/supabase-rls-authenticated-vs-authorized-owner-scope-guide). The real-world weight of this leak class is shown by **[CVE-2025-48757](https://nvd.nist.gov/vuln/detail/CVE-2025-48757)** (CVSS 9.3 CRITICAL, CWE-863), where insufficient RLS on an AI-generation platform made arbitrary tables readable and writable.

### 2.3 Missing `WITH CHECK` — reads are protected while writes pass straight through

AI steps on this one especially easily. It may scope SELECT's `USING` to the owner, yet **omit the `WITH CHECK` on the write side.**

```sql
-- USING は正しい。だが INSERT/UPDATE に WITH CHECK が無い
create policy "update own notes" on public.notes
  for update to authenticated
  using ((select auth.uid()) = user_id);   -- 見える行は本人だけ
  -- ← with check が無い！ 書ける値に制約が無い
```

`USING` controls "which rows are visible" and `WITH CHECK` controls "which values you can write" — **separate gates** ([PostgreSQL Docs](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)). Lacking `WITH CHECK`, you can update one of your own visible rows while rewriting `user_id` to someone else — a write bypass in the direction of *handing off* ownership opens up. Writing them as a pair is mandatory.

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

How mixing up or omitting `USING` and `WITH CHECK` is exploited concretely is covered in [the canonical write-bypass article](/blog/supabase-rls-with-check-using-write-bypass-guide).

### 2.4 RLS never enabled, and confusing the service_role boundary

Satisfied merely by writing `CREATE POLICY`, you forget `ENABLE ROW LEVEL SECURITY` — a state where the policy *exists but is never applied at all.*

```sql
create table public.orders (id uuid primary key, user_id uuid, total int);
-- alter table ... enable row level security;  ← これが無いとポリシは無力
create policy "own orders" on public.orders
  for select using ((select auth.uid()) = user_id);
```

The other is **mixing up the key.** The `service_role` key runs with PostgreSQL's `BYPASSRLS` and ignores RLS entirely ([Supabase Docs](https://supabase.com/docs/guides/database/postgres/row-level-security)). Ask AI to "fetch data from the server" and it may casually write an implementation that leaks the `service_role` client into an API route or the client side. When that happens, **the defensive boundary moves from RLS to where the key is stored,** and no number of RLS policies matters. This sense of boundary is continuous with [IDOR / broken authorization](/blog/nextjs-supabase-idor-broken-authorization-rls-detection-guide), where the code side forgets the ownership check; staples like RLS never enabled, `USING(true)`, and an over-broad GRANT to `anon` are organized with fix-up SQL in the [misconfiguration-detection guide](/blog/supabase-rls-misconfiguration-detection-audit-guide).

### 2.5 Unpinned `search_path` on `SECURITY DEFINER`

When AI generates a helper function, it may attach `SECURITY DEFINER` (run with the definer's privileges) yet not pin `search_path`.

```sql
-- 危険：search_path を固定していない SECURITY DEFINER
create function public.is_member(org uuid) returns boolean
  language sql security definer
as $$ select exists(select 1 from memberships where org_id = org and user_id = auth.uid()) $$;
-- ← set search_path = '' が無い
```

A `SECURITY DEFINER` function can become an entry point for privilege escalation: the caller swaps out `search_path` to make it reference an identically-named object the attacker prepared. Always attach `set search_path = ''` (or a pinned schema) to the definition.

```sql
create function public.is_member(org uuid) returns boolean
  language sql security definer set search_path = ''
as $$ select exists(select 1 from public.memberships where org_id = org and user_id = (select auth.uid())) $$;
```

---

## 3. The flip side of "thinking you spotted it" — confusing forms that AI wrote correctly

I can't drop this, for honesty's sake. **"Looks like a pattern" ≠ "vulnerable."** Beat on AI output mechanically and you tend to false-positive *correct yet confusing* forms. When I hardened my own OSS scanner against real-world repos, the biggest lesson was right here — naive detection reported more than twice the number of real holes as "holes," and most of those were **correct predicates** like the following.

```sql
-- ① service_role ゲート（バックエンド専用）— 一般ユーザーは満たせない。穴ではない
using (auth.role() = 'service_role')

-- ② 参加者束縛（DMの送受信者）— 匿名(uid=null)は満たせない。認可済み
using ((select auth.uid()) in (sender_id, receiver_id))

-- ③ 性能ラッパ付きの所有者束縛 — Supabase公式推奨。正しい
using ((select auth.uid() as uid) = user_id)

-- ④ 型キャスト付きの所有者束縛 — 正しく絞っている
using (auth.uid()::text = user_id::text)
```

① is a **restrictive** gate — `service_role` — the exact opposite of `'authenticated'` (a real hole). ③ is precisely the way of writing recommended in [RLS performance optimization](/blog/supabase-rls-performance-optimization-select-wrap-index-guide). **A tool that reports a recommended pattern as vulnerable loses your trust in one shot.** So a scanner must be designed to positively define "what a hole is" and suppress predicates that an anonymous user can't satisfy — not the vague detection of "reacting to the word `auth`." The details of how I drove this false-positive class to zero against real data are published in [the field-study method](https://github.com/tomodahinata/aegis/blob/main/research/rls-precision-study/README.md).

---

## 4. This isn't a special case — a measured lower bound across 1,000 apps

You might think "our AI isn't that sloppy." Let me answer with numbers. **In a primary survey of 1,000 public Supabase apps, ~9.2% of the apps with RLS had a policy that "authenticates but doesn't scope rows to the owner" (a lower bound, since it's a public-repo sample; owner-non-scoped ≠ immediately vulnerable — it needs confirmation).** This is the level among people who commit their migrations to public GitHub — that is, a relatively careful cohort. Include the private and no-migration apps that never enter the population, and the true rate can only move in the worse direction.

What I want to stress is that this 9.2% means not "9.2% are exploitable" but "**9.2% have a policy that requires confirming the design intent.**" Not hyping it is what turns a number into trust. How the survey population was chosen, how 116,662 policies were parsed, and how precision was kept at `precision 1.0` are all published in [the Supabase RLS security field study](/blog/supabase-rls-security-field-study). I won't re-explain it here — this article's claim is the single point that "this is AI's standard output tendency, not an exception."

---

## 5. My verification workflow — one verification gate in the generation loop

So how do I actually run it? My vibe coding is simply "keep the generation speed, slip one verification step in before shipping." For RLS, it's a three-stage setup.

```text
[1] 生成    Claude Code / Cursor でいつも通りポリシを書かせる
                    │
[2] 貼るだけ  1本のポリシを即判定したい → ブラウザRLSチェッカー
                    │  正しい雛形が欲しい → RLSジェネレータ
                    ▼
[3] 全体scan  リポジトリ横断で漏れを洗う → npx @aegiskit/cli scan
                    │
[4] 回帰固定  直したら二度と戻さない    → pgTAP で allow/deny をCIに
```

**Paste-only (no install; the SQL never leaves the browser).** If you just want to judge one generated policy on the spot, the fully-in-browser [RLS checker](/aegis/rls-checker) is fastest. Paste `using (auth.role() = 'authenticated')` and it instantly classifies it as "authenticated only (confirm needed)"; paste `using ((select auth.uid()) = user_id)` and it's "owner-scoped (OK)." The judgment runs 100% in the browser and the SQL is sent nowhere. Conversely, if you want a template for a correct policy, the [RLS generator](/aegis/rls-generator) lets you specify a table and its ownership column to generate a safe `USING`/`WITH CHECK` pair.

**Scan the whole repo.** To sweep across `supabase/migrations/**.sql` for the §2 boilerplate mistakes, use the free OSS [Aegis](/aegis).

```bash
# インストール不要・設定不要。ローカルで走り、稼働中アプリには触れない（静的解析のみ）
npx @aegiskit/cli scan
```

This is the same detection used in the field study. **What Aegis automates is "horizontal controls" (headers/CSP, rate limiting, CSRF, secret hygiene) and verification of the "shape" of RLS predicates. The "vertical risks" — authorization/IDOR and tenant isolation — it keeps to detection and warning, returning the fix to your design.** I won't claim it "protects you completely" — a tool that advertises that is exactly what breeds the worst complacency: "we installed it, so we're fine."

**Pin regressions.** Once you find and fix it, hand the `aegis fix --format json` fix plan straight to your coding agent to apply, and pin allow/deny in CI with pgTAP. I run this "generate → verify → CI" pattern as part of [spec-driven development](/blog/spec-driven-development-claude-code-ai-agent-production-workflow), where AI handles the implementation while humans hold the spec decisions.

---

## 6. The line the automation can measure, and the line it can't

Finally — break this and everything becomes a lie. **No static analysis can prove the "correctness" of authorization.** A tool sees the "shape" of a predicate, not the "meaning" of your business rules and data model.

| Measurable automatically (mechanized) | Left to human design judgment (audit) |
|---|---|
| Detecting `USING (true)` / `auth.role()='authenticated'` | Is the table really intended to be shared? |
| Missing `WITH CHECK` / RLS never enabled | Is tenant isolation correct as a design? |
| Unpinned `search_path` on `SECURITY DEFINER` | Room for privilege escalation / broken business logic |
| Cross-checking ownership columns vs predicates | Authorization flows spanning multi-stage modules |

Even if the scan finds zero, that's "you haven't stepped on the common boilerplate mistakes," not "the authorization is correct." Zero is a starting point, not the goal. **The judgment of "may this table really be shown to everyone?" can only be made by a human who knows your data model.** Where the automation can protect and where the audit's domain begins is separated out concretely in [the scope of an audit](/blog/nextjs-supabase-security-audit-scope-when-needed-guide).

---

## In summary

Can you trust AI-generated RLS? The answer is "don't trust it as-is, but you don't need to fear it either." Since we know the failures collapse into a handful of patterns, just beat on `USING(true)`, `authenticated`-for-all, missing `WITH CHECK`, RLS never enabled, and an unpinned `search_path` on `SECURITY DEFINER` mechanically on every generation. Start by measuring your own output with the [paste-only RLS checker](/aegis/rls-checker) or `npx @aegiskit/cli scan`.

And don't forget — **a tool only draws the map that says "confirm here"; it can't say "this is correct."** When you want a human who knows the data model to check whether RLS is really *authorization* and whether tenant isolation really holds, start from an [authorization review / audit](/aegis/audit) of your existing app. The power to build fast and the power to build safe are two sides of the same coin.
