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