Supabase RLS for Vibe-Coded Apps: The Policies Your AI Forgot to Write
If your app was built by describing it to an AI and it uses Supabase, there is a good chance every logged-in user can read every other user’s data right now. Not because Supabase is insecure — because Row Level Security is off, or the policy the AI wrote says true. This is the single most common finding in our vibe-coded app reviews, and it takes about an hour to fix properly.
Open the Supabase dashboard → Table Editor. Any table showing “RLS disabled” or “Unrestricted” is readable and writable by anyone holding your public anon key — which is every visitor to your site, since the anon key ships in the browser. Then open Authentication → Policies and look for policies whose expression is just true. Those are the same thing with extra steps.
Why AI tools get this wrong
Supabase’s anon key is meant to be public. The security model assumes that RLS policies on every table decide who can see what. When you ask an AI to “make the app show my tasks”, the fastest path to a working demo is a table with RLS off or a permissive policy, because then nothing blocks the query. The demo works. The tool reports success. Nobody wrote the policy that says only the owner, and nothing in the UI reveals that it’s missing.
This is the concrete version of the authorisation gap in our vibe coding security checklist: the app checks that you’re logged in (Supabase Auth does that well) but never checks that the row belongs to you.
The four policies almost every app needs
Most vibe-coded apps have the same shape: users, and things that belong to users. For any table with a user_id column, the baseline is four policies — one per operation — all keyed on auth.uid():
alter table public.tasks enable row level security;
create policy "owner can read" on public.tasks for select using (auth.uid() = user_id);
create policy "owner can insert" on public.tasks for insert with check (auth.uid() = user_id);
create policy "owner can update" on public.tasks for update using (auth.uid() = user_id) with check (auth.uid() = user_id);
create policy "owner can delete" on public.tasks for delete using (auth.uid() = user_id);
Three details the AI usually misses: insert needs with check (not using), otherwise anyone can insert rows attributed to someone else; update needs both, so a user can’t reassign their row to another account; and the user_id column should default to auth.uid() and be not null, so the client can’t omit it.
The checklist
user_id column leaks who your users are.true for anon or authenticatedplans table). Every one should be deliberate and written down.auth.uid() exists in a memberships row for that resource. Use a security definer helper function to avoid recursive policy errors.service_role. If it’s there, rotate it today and move whatever needed it to a server-side function.security definer runs with elevated rights; it must validate auth.uid() itself. AI-generated RPCs frequently don’t.Tell the AI exactly this
Once you know what “right” looks like, the tool is good at producing it. A prompt that works: “For every table in the public schema, enable RLS and write owner-only policies for select, insert, update and delete keyed on auth.uid() = user_id. Use with check on insert and update. List every table you changed and any table where an owner-only policy doesn’t make sense, and stop so I can decide those.” Then run check 7 yourself — the tool will report success either way.
If the schema doesn’t have a clean user_id on the tables that need one, or ownership is more complicated than “mine vs. not mine”, that’s a data-model conversation — the one check in our 12-point prototype audit that decides whether hardening is enough or the core needs re-spining.
FAQ
Want the policies reviewed before real users find the gap?
Fixed-scope Supabase security review for AI-built apps: every table, every policy, every bucket — with the two-user test done and documented.
