Skip to content
Heartscale

supabasePublished on · Heartscale team

Supabase RLS: how to configure it and test whether your app leaks data

What RLS is, in one sentence

RLS (Row Level Security) is the database rule that decides which rows each person can read, create, change or delete. Without it, whoever can access the table can access the whole table.

Why this matters on Lovable, Bolt and Base44

Apps built with these tools usually talk to the database directly from the browser. To do that, the Supabase publishable key ships with the site. It is meant to be exposed, with one condition: Supabase asks you to turn on RLS on every table before you publish (Supabase, API keys).

The Supabase documentation sums up the risk: a table in an exposed schema without RLS can be read and changed by any role with privileges on it (Supabase, Row Level Security). And in older projects, new tables in the public schema may be created with full privileges granted to the client roles.

The examples below use an orders table with a user_id column that stores the owner of each row. Replace them with your app’s names.

Step 1: find the exposed tables

In the Supabase dashboard, open the Security Advisor. It also runs from the CLI with supabase db advisors (Supabase, Database Advisors). The alerts that matter here:

Alert What it means
0013_rls_disabled_in_public Public table with RLS turned off. Anyone with the project URL can read, edit and delete its rows
0007_policy_exists_rls_disabled There is a policy, but RLS is off. The policy has no effect
0008_rls_enabled_no_policy RLS is on and there is no policy. The API returns nothing
0010_security_definer_view A view that runs with its creator’s privileges and bypasses RLS
0002_auth_users_exposed A view that exposes the login users table

Start with the ERROR-level ones: 0013, 0010 and 0002.

In Lovable Cloud, the list of policies is under More → Database → Manage → Policies. It is read-only: changes are requested from Lovable in the chat (Lovable, Database access). The steps below tell you what to ask for and what to check.

Step 2: turn on RLS

In the SQL Editor:

alter table public.orders enable row level security;

Careful: from this point on, nothing gets through the publishable key until a policy exists (Supabase, Row Level Security). If the app is already live, it will stop showing the orders. Do steps 2 to 4 together, preferably in a test project.

Step 3: review the role privileges

Supabase uses two roles for requests coming from the browser:

  • anon: users who are not logged in;
  • authenticated: users who are logged in.

Revoke everything and grant back only what is needed. If only logged-in users touch orders:

revoke all on table public.orders from anon, authenticated;
grant select, insert, update, delete on table public.orders to authenticated;

Step 4: write one policy per operation

The Supabase docs recommend one policy per operation, always with to, to state which role it applies to. For the case “each user only touches their own orders”:

create policy "read own orders"
on public.orders for select
to authenticated
using ( (select auth.uid()) = user_id );

create policy "create orders as self"
on public.orders for insert
to authenticated
with check ( (select auth.uid()) = user_id );

create policy "update own orders"
on public.orders for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );

create policy "delete own orders"
on public.orders for delete
to authenticated
using ( (select auth.uid()) = user_id );

using and with check are not the same thing:

  • using decides which existing rows the person can see, change or delete.
  • with check decides what the row is allowed to look like after it is created or changed.

On update, write both, as the Supabase docs do. If with check is missing, PostgreSQL also uses using for the new row (PostgreSQL, CREATE POLICY). It works, but it hides the intent: whoever reads the policy later cannot tell whether the omission was deliberate.

A common mistake is to create only the select policy and assume the table is protected. Each operation needs its own.

Step 5: fix the views

A view created in the public schema runs, by default, with its creator’s privileges and ignores the RLS of whoever queries it. The fix Supabase recommends is to create the view with security_invoker=on (Supabase, Database Advisors):

create view public.orders_summary
with (security_invoker = on) as
select user_id, count(*) as total
from public.orders
group by user_id;

Never expose auth.users in a public view. If you need the user’s name and photo, create a profile table with its own RLS.

Step 6: index the columns used in policies

Policies run on every query. To keep the app fast, Supabase asks for two things (Supabase, Row Level Security):

  1. An index on every column used in the policies:
create index orders_user_id_idx on public.orders using btree (user_id);
  1. Write (select auth.uid()) instead of auth.uid(). That way Postgres computes the value once per query, not once per row.

Step 7: test whether it still leaks

Run these tests on your own project.

Test 1: as a stranger. Use the publishable key, the same one that is on the site, and call the Supabase REST API (Supabase, REST API):

curl "https://YOUR-PROJECT.supabase.co/rest/v1/orders?select=*" \
  -H "apikey: YOUR_PUBLISHABLE_KEY"

With the revoke from step 3, you should get a permission error or an empty list. If orders come back, any visitor to the site can do the same.

Test 2: as another customer. Create users A and B. As A, create an order. Log in as B and try to view, edit and delete A’s order through the app, changing the ID in the address if the app uses IDs in the URL. Lovable asks for this same test before publishing (Lovable, Security best practices).

Run the Security Advisor again at the end. The alerts from step 1 should be gone.

The “new row violates row-level security policy” error

This error shows up after RLS is turned on, and it is a good sign: the database rejected a row. According to the PostgreSQL documentation, when the with check expression is not true for the new row, the database returns an error instead of writing it (PostgreSQL, CREATE POLICY).

The cause is almost always one of these:

  • there is no insert policy for the role that is writing;
  • the app does not send user_id, or sends one that differs from the logged-in user;
  • the person is not logged in, and the request arrives as anon.

Do not fix it with a policy that accepts everything (with check (true)). Fix the app so it sends the right user_id, or fill the column with a default value of auth.uid().

The same error in storage means the file bucket is missing a policy. The logic is the same.

Be careful with the secret key and Edge Functions

The secret key (sb_secret_..., or the legacy service_role) bypasses every policy. Supabase says never to use it in the browser, in a distributed app or in version control (Supabase, API keys).

It belongs in server functions. But there, RLS no longer protects anything: the function has to check on its own who called it and what that person is allowed to do (Lovable, Security best practices). Review every Edge Function that uses the secret key.

If you find the secret key in the site’s code, rotate it: create a new one, replace it everywhere and only then deactivate the old one.

Order a Heartscale diagnostic

In the Heartscale diagnostic, our team reads every table, policy, view and function in your Supabase or Lovable Cloud project. We also test access with the public key and check where the secret key is used. You receive the list of what is open, ranked by severity, and the price of the fix.

The diagnostic costs US$ 79 and is credited toward the fix if you go ahead. We only work on apps whose owners can prove they own them.

Order the diagnostic for my app

Start with the diagnostic.

The diagnostic costs US$ 79, paid by payment link. We deliver the report in 2 business days and walk you through the findings in a meeting. The fee is credited if you hire the service within 15 days. Everything we set up stays in your name.

Reduces the risk, does not eliminate it. We only work with the app’s owner, with proof of ownership.