Gitrex Technologies
SupabaseRLSSecurity

Supabase RLS mistakes I keep finding in AI-generated apps

The five row-level security failures that show up in almost every Lovable, Bolt and Cursor build, why the AI makes them, and the two SQL queries that expose them in ten minutes.

· Founder, Gitrex Technologies

4 min read

Row-level security is the whole security model of a Supabase app. The anon key is public, the REST API is public, the only thing between a visitor and your tables is the policies on those tables. So when an AI tool writes the policies, the policies are the app's security.

I've now read enough generated schemas to know what to expect before I open one. These are the failures, roughly in order of how often I see them.

"Enable RLS" with no policies, then the fix that makes it worse

Enabling RLS on a table with no policies blocks everything, including the app's own reads. The generated code stops working, the tool sees the error, and the fastest fix it can reach for is either disabling RLS again or adding a policy like this:

create policy "Enable read access for all users"
on public.profiles for select
using (true);

That policy name is a tell. It's what the Supabase dashboard template suggests, and it means exactly what it says: everyone, including the logged-out public, can read every row. On a profiles table that usually means emails, sometimes phone numbers, occasionally Stripe customer ids.

I've seen this on orders, invoices, messages and once on a table called internal_notes.

Policies that check whether you're logged in but not who you are

The second most common shape:

using (auth.role() = 'authenticated')

This stops anonymous visitors and nobody else. Any account can read any other account's rows. Sign up with a throwaway email and you're in. What the policy should say is something like:

using (auth.uid() = user_id)

and even that only works if user_id is actually populated on insert, which brings me to the next one.

user_id is nullable, or set from the client

If the insert policy is with check (auth.uid() = user_id) but the column is nullable, an insert that omits user_id passes the check with null = null, which is not true in SQL, so it fails. Good. But I regularly find the generated code side-stepping this by inserting user_id from the client, from a value it read earlier. Now the client decides which user owns the row.

The fix is a default on the column so the database sets it and the client can't lie:

alter table public.orders
  alter column user_id set default auth.uid(),
  alter column user_id set not null;

Views and functions that quietly bypass RLS

Postgres views run with the permissions of the view's owner by default, and the owner in a Supabase project is postgres, which bypasses RLS. So a generated dashboard_stats view that joins orders to profiles exposes both, policies or not, to anyone who can select from the view.

Same story for functions marked security definer. AI tools reach for it whenever a function hits a permission error, because it makes the error go away.

For views, the fix on Postgres 15 and up is one line:

alter view public.dashboard_stats set (security_invoker = on);

For functions, it's usually removing security definer and fixing the underlying policy instead.

Storage buckets with no policies at all

RLS on tables gets at least some attention. Storage buckets get none. A public bucket called avatars is fine. A public bucket called uploads that holds signed contracts and ID scans is not, and I've seen both with the same configuration because the tool created one bucket and reused it.

Check storage.objects policies the same way you'd check a table, and check whether the bucket itself is marked public in the dashboard.

The ten-minute check

Two queries. Run them in the SQL editor of your project.

Tables with RLS switched off:

select tablename
from pg_tables
where schemaname = 'public'
  and rowsecurity = false;

Policies that let everyone through:

select tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public'
  and (qual = 'true' or with_check = 'true'
       or qual like '%authenticated%');

Everything the second query returns deserves a second look. Not all of it is wrong. A select policy of true on a public_posts table is exactly what you want. A select policy of true on profiles is a breach waiting for someone to notice.

Then the test that doesn't require trusting your own reading of SQL:

curl "https://YOUR-PROJECT.supabase.co/rest/v1/profiles?select=*" \
  -H "apikey: YOUR_ANON_KEY" \
  -H "Authorization: Bearer YOUR_ANON_KEY"

Rows back means public.

Why the AI keeps doing this

It's not malice and it's not stupidity. The models are trained on a lot of tutorials, and tutorials disable RLS "for simplicity". When the generated app hits a permission error, the loss function is "make the error stop", and the shortest path to that is always a looser policy. Nobody in the loop is asking "who should be able to see this row?", because that's a product question and the tool only sees code.

That question is most of what a security and database review is. Someone sits down with your tables and asks it, row by row, and writes the answer into the policies. It is not glamorous work. It's also the difference between a launch and a disclosure email.

Wajahat Shaw, founder of Gitrex Technologies

Wajahat Shaw

Founder of Gitrex Technologies. Senior engineer; builds and rescues software for founders, most of it React Native, Next.js and Supabase. Writes here about what actually breaks and how to check for it. More about Gitrex

Keep reading
SecuritySupabaseBolt

Where exposed keys hide in Lovable and Bolt builds

Supabase service_role keys, Stripe secrets and OpenAI keys end up in the browser bundle more often than you'd think. How to find them on your own live site, and how to rotate them safely.

4 min read