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.
- What is Row-Level Security and why does Lovable get it wrong
- How to check if your Lovable app has RLS issues right now
- Why anon keys + no RLS is the most expensive failure mode
- How to enable RLS and write your first policies
- Common pitfalls when fixing RLS
- 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:
- Read every row of your users table, including emails, hashed passwords (if you stored them yourself), and any other PII
- Insert garbage rows into any table, polluting your data or filling your database with spam
- Update or delete rows that belong to other users
- Trigger expensive operations (Supabase egress bills are based on bandwidth, and someone scraping your tables in a loop racks up real charges)
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.
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.
Related Supabase issues to check after RLS is fixed
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
- Lovable connects Supabase with the anon key, which is fine
- RLS is off by default on every new Postgres table
- If you didn't manually enable RLS and write policies, anyone with DevTools has full access to your data
- To fix:
ALTER TABLE x ENABLE ROW LEVEL SECURITY+ at least one policy per operation - Use
auth.uid()in policies to scope access to the current user - After enabling RLS, test from an incognito window and as a different logged-in user
- Storage, Realtime, and Edge Functions each have their own access control layer
If your app takes payments, this is the other failure mode that costs founders the most. AI builders rarely include signature verification by default.
If your app has any AI features, you also need rate limiting. One user with a loop script can burn $5,000 in OpenAI credits overnight.
The pillar piece explaining the meta-pattern: why AI-built apps consistently ship with the same five production gaps, and what to do about it.
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