Skip to main content
友田 陽大
Vibe coding: shipping AI-generated code safely
Supabase
RLS
セキュリティ
バイブコーディング
AI駆動開発
PostgreSQL
Next.js

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
Reading time
14 min read
Author
友田 陽大
Share

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 and the vibe-coding 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", 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 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), 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 mistakeWhy AI writes it that wayThe one move to spot it
2.1Wide-open USING (true)Takes "get it working first" literallyWide open despite an ownership column?
2.2auth.role() = 'authenticated'Literal translation of "a logged-in user can read"Does the predicate reference user_id?
2.3Missing WITH CHECKThinks only of SELECT and forgets writesIs there a paired WITH CHECK on INSERT/UPDATE?
2.4RLS never enabled / service_role misuseSatisfied by CREATE POLICY, forgets ENABLEIs there ENABLE ROW LEVEL SECURITY? Where's the key?
2.5Unpinned search_path on SECURITY DEFINERCreates the function but doesn't know the privilege-escalation surfaceDoes 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.

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).

-- 危険:ログイン済みかしか見ていない(=全ログインユーザーに全行)
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.

-- 安全:行を呼び出し元の所有に束縛
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). 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. The real-world weight of this leak class is shown by 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.

-- 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). 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.

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.

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.

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). 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, 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.

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.

-- 危険: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.

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.

-- ① 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. 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.


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. 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.

[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 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 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.

# インストール不要・設定不要。ローカルで走り、稼働中アプリには触れない(静的解析のみ)
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, 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 enabledIs tenant isolation correct as a design?
Unpinned search_path on SECURITY DEFINERRoom for privilege escalation / broken business logic
Cross-checking ownership columns vs predicatesAuthorization 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.


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 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 of your existing app. The power to build fast and the power to build safe are two sides of the same coin.

Frequently asked questions

If I tell the AI to 'write RLS safely,' does that prevent this problem?
Don't expect too much. Research shows the security score of generated code stays flat even as models get smarter; AI is great at 'code that works' but doesn't guarantee 'a structure that won't break.' What works is a verification gate, not an instruction. Keep the generation speed, but before shipping, beat on the predicates mechanically with npx @aegiskit/cli scan or pgTAP and lock down owner scoping and the presence of WITH CHECK.
Is USING (true) always vulnerable?
No. If the table is 'public data intended to be shown to everyone,' USING (true) is legitimate — a country list, categories, public articles, feature flags. It's dangerous when USING (true) sits on a table that has an ownership column like user_id or tenant_id. The deciding axis is 'is each row a user-specific asset, or reference data shared by everyone?' If there's an ownership column yet it's wide open, you need to confirm the intent.
If the SELECT-side policy is correct, are writes safe?
No. USING controls 'which rows are visible' and WITH CHECK controls 'which values you can write' — they're separate gates. AI tends to scope SELECT's USING to the owner but omit WITH CHECK on INSERT/UPDATE. Then reads are limited to your own rows, yet a write bypass opens up where you can create a row stuffed with someone else's user_id or rewrite user_id. Always attach the paired WITH CHECK to a write policy.
If the scan finds zero, can I consider the RLS safe?
No. Zero means 'you haven't stepped on the common boilerplate mistakes,' not proof that 'the authorization is correct.' A tool sees the 'shape' of a predicate, not the 'meaning' of your business rules and data model. Whether the intent of a shared table is sound, whether tenant isolation is correct as a design, and whether there's room for privilege escalation can only be assured by human review and audit. Zero is a starting point, not the goal.
How do I choose between the paste-only checker and the whole-repo scan?
When you just want to check a single policy on hand, the fully-in-browser RLS checker is fastest — paste it and it classifies owner-scoped / authenticated-only / wide-open, and the SQL is never sent anywhere. To sweep for gaps across your whole repo's migrations, use npx @aegiskit/cli scan; if you want a template for a correct policy, the RLS generator; and to stop regressions, pin it in CI with pgTAP — combining them in that order is what works in practice.

References

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

That AI-generated code — safe to deploy as-is?

After “it works” comes “it doesn't leak” — scan your project free with one command

Aegis, an MIT-licensed OSS, automates the controls AI tends to skip (headers/CSP, rate limiting, input validation, secrets) and detects-and-warns on vertical risks in authorization and RLS design. The fix plan from `aegis fix --format json` pastes straight into Claude Code or Cursor.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading