Reviewing AI-Generated RLS Policies: 10 Common Mistakes
AI-generated Row Level Security policies usually fail in a few predictable ways: RLS never turned on, write policies that allow true, ownership columns users can change, authorization based on data users control, and views or functions that skip RLS entirely. Each one looks fine in a quick read and passes a test run as the database owner. Below are ten mistakes that come up again and again in policies written by tools such as Lovable, Bolt, Cursor and Claude Code, each with the broken SQL, why it leaks, and the fix.
Everything here is plain Postgres plus the auth.uid() and auth.jwt() helpers of Supabase-style backends, so it applies to Supabase, StitchDB and any stack built on PostgREST and GoTrue.
#Why AI tools get RLS wrong
A model optimizes for "the app works now". A policy that is too open makes the app work. A policy that is too strict produces an empty list and a bug report. So when the model is unsure, it opens things up. It also tests by running SQL, and SQL from an AI tool usually runs as the table owner, which bypasses RLS completely. "I checked, the query returns rows" proves nothing about what a user can see.
Keep that in mind as you read each policy: the question is never "does the app work?" but "what can a stranger with the anon key do?"
#1. RLS is never enabled
create table public.invoices (
id bigint generated always as identity primary key,
user_id uuid not null,
amount_cents int not null
);
-- no "enable row level security"Tables created with SQL start with RLS off. On a backend with an auto-generated REST API, anyone with the public anon key can then read, insert, change and delete every row.
Fix: enable RLS in the same migration that creates the table, then add policies.
alter table public.invoices enable row level security;StitchDB's RLS Guard does this automatically for every new table in public, however it was created; it is on by default for new projects (exposure check).
#2. "Allow all for authenticated users"
create policy "Enable all for authenticated users" on public.invoices
for all to authenticated
using (true) with check (true);This is a very common AI-generated policy. "Authenticated" sounds restrictive, but on most apps anyone can sign up. Every signed-in user can now read and edit every invoice.
Fix: tie rows to their owner.
create policy "Owners manage their invoices" on public.invoices
for all to authenticated
using (user_id = (select auth.uid()))
with check (user_id = (select auth.uid()));#3. A with check that lets users give rows away
create policy "Users update own rows" on public.documents
for update to authenticated
using (owner_id = auth.uid())
with check (true);using decides which existing rows a user may update. with check decides what the row may look like afterwards. With with check (true), a user can set owner_id to someone else's id, or move a row into another team. The same goes for an insert policy with with check (true): users can create rows that claim to belong to anyone.
Fix: repeat the ownership rule in with check. Postgres uses the using expression for both when you leave with check out of an update policy, so leaving it out is safer than writing true.
create policy "Users update own rows" on public.documents
for update to authenticated
using (owner_id = (select auth.uid()))
with check (owner_id = (select auth.uid()));Better still, make ownership columns default to auth.uid() and stop clients from sending them at all.
#4. Authorization from user_metadata
create policy "Admins read everything" on public.orders
for select to authenticated
using ((auth.jwt() -> 'user_metadata' ->> 'role') = 'admin');user_metadata is set by the user. Any signed-in user can call auth.updateUser({ data: { role: 'admin' } }) and, on their next token, read every order.
Fix: use app_metadata, which only the server can set, or a table of roles that users cannot write (roles and JWT claims).
using ((auth.jwt() -> 'app_metadata' ->> 'role') = 'admin')#5. A membership table anyone can write to
Team apps often generate this pair:
create policy "Members read team projects" on public.projects
for select to authenticated
using (team_id in (select team_id from public.team_members where user_id = auth.uid()));
create policy "Users can join teams" on public.team_members
for insert to authenticated
with check (user_id = auth.uid());The first policy is fine. The second lets any user insert a membership row for any team_id, as long as the user_id is their own. They join a team of their choice and read its projects.
Fix: only admins add members, or members join through an invite function that checks a token. If your backend has built-in organizations, use them: StitchDB's auth.is_member(org_id) and auth.has_role('admin', org_id) look up membership live, and invites go through org_invite and org_accept_invite (organizations).
#6. A new permissive policy that opens everything
Permissive policies combine with OR. If a table has "owners read their rows" and the model later adds:
create policy "Public can read" on public.profiles
for select to anon, authenticated using (true);to make a public profile page work, the owner-only policy no longer matters. Every row and every column is readable.
Fix: expose only what is meant to be public, with a narrow condition or a separate view, and use a restrictive policy for rules that must always hold:
create policy "Public profiles are readable" on public.profiles
for select to anon, authenticated using (is_public);
-- Always required, in addition to any permissive policy
create policy "MFA for payouts" on public.payouts
as restrictive for all to authenticated
using ((auth.jwt() ->> 'aal') = 'aal2');Remember that RLS filters rows, not columns. If a public row has a private column, use a view or a separate table.
#7. Views that bypass RLS
create view public.order_summary as
select user_id, count(*) as orders, sum(amount_cents) as total
from public.orders group by user_id;A view runs with the permissions of its owner unless security_invoker is on, so the policies on orders are skipped and every user's totals are readable through the API. Materialized views have no RLS at all.
Fix:
alter view public.order_summary set (security_invoker = on);Keep materialized views out of exposed schemas.
#8. security definer functions without checks
create function public.delete_project(p_id bigint) returns void
language sql security definer as $$
delete from public.projects where id = p_id;
$$;Functions in an exposed schema can be called through the API as RPC. security definer runs them as their owner, so this deletes any project for anyone who calls it. Without a fixed search_path, it can also be tricked into calling a different object with the same name.
Fix: prefer security invoker (the default) so RLS applies. If you need definer rights, check the caller inside and pin the search path:
create or replace function public.delete_project(p_id bigint) returns void
language plpgsql security definer set search_path = '' as $$
begin
delete from public.projects
where id = p_id and auth.has_role('admin', org_id);
end $$;#9. The service_role key in browser code
const db = createClient(import.meta.env.VITE_URL, import.meta.env.VITE_SERVICE_ROLE_KEY)The model hit an RLS error, found that the service_role key makes it go away, and put it in a VITE_ variable. Everything with that prefix is shipped to the browser. The service_role key bypasses RLS and can manage users. This is not a policy mistake, but it makes every policy irrelevant.
Fix: only the anon key goes in client code. Rotate the service_role key if it was ever deployed, and move privileged work into an Edge Function or server route.
#10. Policies that are correct but slow
using (user_id = auth.uid())This is correct, but on large tables the function may be evaluated per row, and without an index on user_id every request scans the table.
Fix: wrap the call in a subquery so Postgres evaluates it once per statement, and index the columns your policies filter on:
using (user_id = (select auth.uid()))
create index on public.invoices (user_id);#How to review a policy in five minutes
Use this checklist for every migration an AI tool writes:
- Every new table in
publichasenable row level security. - No write policy uses
trueinusingorwith check. - Every insert and update policy has a
with checkthat repeats the ownership or team rule. - Authorization reads
app_metadata, a roles table or membership helpers, neveruser_metadata. - Membership and role tables are not writable by ordinary users.
- Views have
security_invoker = on; materialized views are not exposed. security definerfunctions check the caller and setsearch_path.- Only the anon key appears in client code.
- Columns used in policies are indexed.
- You tested as two different users, not as the owner.
#Test as a real user, not as the owner
On StitchDB, three tools do the testing for you:
- Check access in Database › Policies runs every policy as a user you pick (or an anonymous visitor) against a real row, and explains in plain words which policy lets them in or keeps them out. It runs in a transaction that is always rolled back (Row Level Security).
- Exposure check runs a live test per table as an anonymous visitor and as a random signed-in user and shows how many rows each can read, update and delete, plus a policy linter. The
check_exposureMCP tool returns the same report, so you can tell your AI tool to check its own work after every migration. - Advisors lists tables without RLS, views that bypass RLS, exposed sensitive columns, anonymous writes and functions with a mutable
search_path, each with SQL to fix it (advisors).
If your agent writes migrations through an agent key, RLS changes and privilege changes wait for your approval before they run (approvals). That puts the review above in front of a human by default.
For the fundamentals behind all of this, see Row Level Security in Postgres and the Postgres security checklist.
#FAQ
#Why does my query return rows in the SQL editor but not in the app?
The SQL editor and most AI tools run as the table owner, which bypasses RLS. The app runs as anon or authenticated, so only rows your policies allow come back. Test as a real user instead.
#Is to authenticated using (true) ever fine?
For reading data that every signed-in user should see, such as a list of plans, yes. For anything users write, or anything private, no.
#Can I let the AI write policies at all?
Yes, as drafts. Review each one against the checklist above, then test it as two different users before it reaches production.
#Does enabling RLS without policies break my app?
It hides every row from the API and blocks writes for anon and authenticated until you add policies. That is the safe failure: an empty list instead of a data leak.
Check what a stranger can see in your database right now: create a free project or import yours, then run the exposure check.