Guide / Supabase / Security

Supabase Row-Level Security for Lovable Apps

If you built your app on Lovable and connected Supabase, your tables almost certainly have Row-Level Security disabled. Here's why that's a critical issue, how to verify it in 60 seconds, and how to fix it properly.

The short version. Lovable connects your app to Supabase using the anon key, which ships in your JavaScript bundle and is visible to anyone. Supabase expects you to enforce access control through Row-Level Security policies on every table. If you didn't manually enable RLS and write at least one policy per table, anyone who opens DevTools can SELECT, INSERT, UPDATE, or DELETE on your entire database.
In this guide
  1. What is Row-Level Security and why does Lovable get it wrong
  2. How to check if your Lovable app has RLS issues right now
  3. Why anon keys + no RLS is the most expensive failure mode
  4. How to enable RLS and write your first policies
  5. Common pitfalls when fixing RLS
  6. Related Supabase issues to check after RLS is fixed

What is Row-Level Security and why does Lovable get it wrong

Row-Level Security (RLS) is a Postgres feature that controls which rows in a table a given user is allowed to see or modify. When RLS is enabled on a table, every query passes through a set of policies you define. The policies have access to auth.uid(), the ID of the currently authenticated user, and they decide whether the query is allowed to return or change a given row.

Supabase is built on Postgres. The security model Supabase assumes you're using is: connect from the client with the anonymous key (which is safe to expose publicly), and let RLS policies on each table decide who can do what.

This model works perfectly when implemented correctly. The problem is that RLS is off by default on every new Postgres table. When Lovable generates a table for your app, that table starts with RLS disabled. Lovable does not automatically enable RLS, and it does not automatically write policies. Both are your responsibility.

The result: a freshly-generated Lovable app, connected to a freshly-generated Supabase database, with the anon key exposed in the JS bundle (as it should be), and zero policies enforcing access control. Anyone who opens DevTools can run any query they want against any table.

How to check if your Lovable app has RLS issues right now

Three quick checks. Each takes under a minute.

Check 1: The Supabase Dashboard

Open supabase.com/dashboard, select your project, and click Database → Tables. For every table in your public schema, look at the "RLS" column. If any table shows "Disabled" or has no shield icon, that table is wide open.

Pay special attention to tables that hold user data: profiles, users, messages, orders, subscriptions, anything with personally identifiable information or payment data.

Check 2: The DevTools query test

Open your deployed app in an incognito window (so you're not logged in). Open DevTools, paste this in the console, and replace YOUR_PROJECT_REF with your Supabase project reference and YOUR_ANON_KEY with the anon key from your app's bundle:

fetch('https://YOUR_PROJECT_REF.supabase.co/rest/v1/profiles?select=*', {
  headers: {
    'apikey': 'YOUR_ANON_KEY',
    'Authorization': 'Bearer YOUR_ANON_KEY'
  }
}).then(r => r.json()).then(console.log)

If this returns rows from your profiles table while you're not logged in, RLS is off (or your policies are too permissive). If it returns an empty array, a 401, or a 403, your access control is working.

Try this against every table that should be private. users, messages, orders, anything with sensitive data. If any of them returns data to an unauthenticated request, you have a critical issue.

Check 3: The "stranger test"

Sign up for your own app with a second email. Log in as the second user, open DevTools, and try to fetch data that should belong to your first user. If the second user can see the first user's private data, your policies aren't scoped to auth.uid() correctly.

Why anon keys + no RLS is the most expensive failure mode

API keys leaking into the JavaScript bundle is a well-known issue. But the anon key isn't actually a secret. Supabase explicitly designs it to be shipped to the client. The problem isn't that the anon key is exposed; the problem is what the anon key is allowed to do without RLS.

By default, when RLS is off, the anon key has full SELECT, INSERT, UPDATE, and DELETE privileges on every table in your public schema. Someone who finds your app on the internet can:

This is the most expensive failure mode I see in Lovable audits because it doesn't break visibly. Your app appears to work fine. Your customers don't notice anything wrong. The only signals that something is leaking are unusual database egress bills (which most founders attribute to "growth"), or the worst-case scenario: a public dump of your data.

Important

Some Lovable apps use the Supabase service role key in client-side code as a workaround for RLS being off. Do not do this. The service role key bypasses RLS entirely and has full admin access. Shipping it to the client is far worse than leaving RLS off. If you find a service role key in your bundle, rotate it immediately, then come back and fix RLS properly.

How to enable RLS and write your first policies

For each table, you need to do two things: enable RLS and write at least one policy. Enabling RLS without writing a policy blocks all access by default, which will break your app. Both steps are necessary.

Step 1: Enable RLS on every table

Run this in the Supabase SQL editor, replacing table names with your own:

ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- repeat for every table in your public schema

Or do it through the Supabase Dashboard: Database → Tables → [table name] → toggle RLS on.

Step 2: Write policies for each table

Once RLS is on, you need policies for each operation (SELECT, INSERT, UPDATE, DELETE) you want to allow. The most common pattern is "users can only see and modify their own rows":

-- Users can read their own profile
CREATE POLICY "Users can view own profile"
  ON profiles FOR SELECT
  USING (auth.uid() = id);

-- Users can update their own profile
CREATE POLICY "Users can update own profile"
  ON profiles FOR UPDATE
  USING (auth.uid() = id);

-- Users can insert their own profile (typically on signup)
CREATE POLICY "Users can insert own profile"
  ON profiles FOR INSERT
  WITH CHECK (auth.uid() = id);

The key piece is auth.uid(), which returns the ID of the currently authenticated user (or NULL if no one is logged in). The policy says: "this query is only allowed if the row's id matches the current user's ID."

Step 3: Adapt the pattern to your schema

For tables where rows belong to a user via a foreign key (like messages with a user_id column):

CREATE POLICY "Users can view own messages"
  ON messages FOR SELECT
  USING (auth.uid() = user_id);

CREATE POLICY "Users can insert own messages"
  ON messages FOR INSERT
  WITH CHECK (auth.uid() = user_id);

For tables that should be readable by everyone (like a public posts table or a list of products):

CREATE POLICY "Public read"
  ON products FOR SELECT
  USING (true);

-- Only allow logged-in users to insert
CREATE POLICY "Authenticated insert"
  ON products FOR INSERT
  WITH CHECK (auth.uid() IS NOT NULL);

Step 4: Verify each policy after writing it

After enabling RLS and writing policies, run the three checks from earlier again. RLS should now block unauthenticated queries on private tables. Logged-in users should only see their own rows. Allowed public reads should still work.

Common pitfalls when fixing RLS

Pitfall 1: Enabling RLS without writing policies

If you enable RLS on a table but don't write any policies, every query against that table will return an empty result (for SELECT) or fail (for INSERT/UPDATE/DELETE). Your app will appear broken. You need at least one policy per operation you want to allow.

Pitfall 2: Writing policies that always return true

A policy of USING (true) effectively disables RLS for that operation. This is sometimes intentional (public read on a non-sensitive table), but is often used by accident or as a "temporary fix" that becomes permanent. Audit every USING (true) policy and confirm it's intentional.

Pitfall 3: Forgetting the WITH CHECK clause on INSERT and UPDATE

For SELECT and DELETE, you use USING. For INSERT, you use WITH CHECK. For UPDATE, you use both: USING controls which rows can be updated, and WITH CHECK controls what the row is allowed to look like after the update. Missing WITH CHECK on an UPDATE policy means a user can update a row they own to make it look like it belongs to someone else.

Pitfall 4: Service role usage in client code

The Supabase service role key bypasses RLS entirely. It should only ever be used on the server (in edge functions, server-side code, or admin scripts). If you see a service role key in your JavaScript bundle, your entire RLS setup is meaningless. Rotate the key immediately and remove it from client code.

Pitfall 5: Joins breaking when RLS is enabled

When you query a table with a join (e.g., SELECT * FROM messages JOIN profiles ON ...), both tables' RLS policies apply. If the join touches a table with restrictive policies, the joined query may return fewer rows than expected, or none at all. Test joins specifically after enabling RLS.

Fixing RLS on your public schema tables doesn't fix every Supabase access control issue. Three more places to check:

Storage policies are separate

Supabase Storage (file uploads, images, etc.) uses a separate set of policies on the storage.objects table. Enabling RLS on your application tables does not affect storage. If your app allows file uploads, write storage policies separately. Default behavior: anyone can read any uploaded file unless you say otherwise.

Realtime subscriptions need RLS too

If your app uses Supabase Realtime (live updates over websockets), Realtime respects the RLS policies on the underlying table. But Realtime also requires you to enable Realtime on the table (separate toggle) and write a policy that allows the SELECT operation for subscribed users.

Edge functions still need to enforce auth

Edge functions (Supabase's serverless functions) often run with the service role key for legitimate reasons. They need to verify the user's JWT manually and check permissions in code, since they bypass RLS. Don't assume RLS is doing the work for you in edge function code.

The TL;DR

Want this done for you?

If you'd rather not spend a weekend rewriting policies and testing edge cases, Rivetz audits and hardens Lovable apps for production. The audit runs $1,000 and takes 3 business days. The cleanup runs $3,500 and fixes every CRITICAL and HIGH severity issue including RLS, secrets, rate limits, and webhook security.

100% async. No calls. No scope creep. Fixed price.

See offers See full checklist