Blog / 4 min read

Supabase row level security, explained for founders

Supabase row level security decides which rows each user can read and change. How policies work, and the common mistakes that leak data.

Supabase row level security (RLS) decides which rows each user can read, add, change or delete. Your app's public key sits in every visitor's browser, so without RLS anyone who copies it can query the tables your API exposes. If you built on Supabase with Lovable, Bolt or Cursor and clicked past the RLS warnings to get the demo working, fix this before real customer data goes in.

What row level security does

Postgres runs two checks before a request touches a table. Grants decide whether a role may run an operation (select, insert, update or delete) on the table at all. Policies decide which rows that operation can touch. RLS is the second check: it adds a hidden where clause to every query, based on who is asking.

Requests from your app use one of two roles. anon is for visitors who aren't logged in, and authenticated is for those who are. Turning RLS on with alter table bookings enable row level security; blocks everything until you add policies. You won't get an error. Queries just come back empty.

How a policy is written

Each policy covers one operation and one role, and says which rows qualify:

create policy "users read their own bookings"
on bookings
for select
to authenticated
using (user_id = (select auth.uid()));

create policy "users insert their own bookings"
on bookings
for insert
to authenticated
with check (user_id = (select auth.uid()));

using filters the rows that already exist. with check tests the row as it will look after the write, so a user can't insert a booking under someone else's ID. An update policy needs both: using for which rows they may change, and with check for what they may change them into.

Wrap auth.uid() in a select

Write auth.uid() on its own and Postgres may call it again for every row it checks. Write (select auth.uid()) and it runs once per query. On a table with many rows, this one change makes a big difference.

Also index the column each policy filters on (user_id, salon_id or similar), so Postgres can use the index instead of reading the whole table. Slow policies are one of the usual reasons a Lovable app gets slow.

Mistakes that leak or hide data

A write policy with no read policy

The insert works, but the user can't read the row back, so the app looks broken even though the data is saved. Most tables need a select policy next to every write policy.

Trusting user metadata

Users can edit raw_user_meta_data themselves through the client library. A policy that checks a role or team stored there can be beaten by someone editing their own profile. Keep anything a policy relies on in raw_app_meta_data, which only your server can change, or in a table of your own.

Policies that check each other

If the policy on table A reads table B, and B's policy reads A, Postgres stops with an "infinite recursion detected in policy" error. Break the loop with a security definer function that looks up the relationship once.

Testing by clicking around

A blocked query returns no rows. It doesn't raise an error. So a policy can look fine in a five-minute manual test and still let the wrong rows through. Write a few pgTAP tests in supabase/tests/ and run supabase test db before every deploy. Test as two different users, and check that neither can see the other's rows.

Multi-tenant apps

In a multi-tenant SaaS, many businesses share one database, and none of them may see another's data. Purity White AI, the salon platform we built, works like that: every salon gets its own booking mini-site, and one salon must never see another salon's clients.

In a Supabase app, that rule lives in your policies. Every policy has to check the business as well as the user. Get it wrong on one table and one customer's list shows up in another customer's account.

Where to start

If you inherited a Supabase schema from an app builder, list every table, check that RLS is on, and read each policy against the mistakes above. If you'd rather hand it over, our prototype rescue health check covers RLS along with exposed keys and payments, and ends with a written report and a fixed quote.

Starting a new product? We design the policies alongside the first table as part of SaaS development. That's far less work than checking forty tables after launch.

Have a project in mind?

A few lines about what you need is enough to start. We work European hours from Lahore and reply to every message within 24 hours. If it helps, we'll sign an NDA before the first call.