Supabase Row-Level Security Explained (for People Who Didn't Write the Policies)
If your app talks to Supabase from the browser, row-level security is the only thing standing between your users' data and anyone who opens the developer tools. What RLS is, how to read the policies your AI tool wrote, the four mistakes that leave data exposed, and how to test it yourself.
Lovable, Bolt, and many other AI builders pair a browser-based frontend with Supabase. It's a good combination — a real Postgres database, authentication, and storage, with no backend server to run. But it rests on one feature working correctly, and if you didn't write the configuration yourself, you may not know whether it does.
That feature is row-level security (RLS). This guide explains it in plain terms, shows how to read what's already set up, and how to check that it's actually protecting anything.
Why this matters so much
In a traditional app, the browser talks to your server, and your server talks to the database. The server decides what each user may see.
In a Supabase-from-the-browser app, the browser talks to the database directly. It does so with a key that's embedded in your frontend code — the "anon" or "publishable" key. That key is meant to be public: anyone can open your site, view the source, and copy it.
So what stops someone using that public key to read your entire users table? Only RLS. It's a set of rules, inside the database, that say which rows each request is allowed to see or change.
No RLS, and the public key opens everything. This isn't hypothetical: in 2025 a researcher found that around 170 of roughly 1,600 Lovable apps he sampled exposed user data this way (tracked as CVE-2025-48757).
RLS in one paragraph
RLS is switched on per table. Once it's on, the table denies everything by default. You then add policies — rules that allow specific operations for specific rows. A policy might say "a logged-in user can read rows in orders where user_id matches their own ID." Requests that don't match any policy get nothing back.
Step 1: check RLS is enabled on every table
In the Supabase dashboard, open the Table Editor or Authentication → Policies. Each table shows whether RLS is enabled. The dashboard's Security Advisor also flags tables without it.
You can check with SQL too:
select tablename, rowsecurity
from pg_tables
where schemaname = 'public';
Every table in public should say true. Pay particular attention to tables created by running SQL — RLS isn't switched on automatically for those the way it is for tables made in the dashboard.
To enable it:
alter table public.orders enable row level security;
Note what happens next: with RLS on and no policies, the table returns nothing to the browser. If your app suddenly shows empty lists after this step, RLS is working — now you need policies.
Step 2: read the policies
A policy looks like this:
create policy "Users can read their own orders"
on public.orders
for select
to authenticated
using ( (select auth.uid()) = user_id );
Reading it piece by piece:
for select— which operation:select(read),insert,update,delete, orall.to authenticated— who it applies to: logged-in users.anonmeans logged-out visitors.using (...)— the condition a row must meet.auth.uid()is the ID of the logged-in user making the request. So: only rows whoseuser_idis yours.
For inserts and updates there's a second clause, with check (...), which checks the row being written:
create policy "Users can create their own orders"
on public.orders
for insert
to authenticated
with check ( (select auth.uid()) = user_id );
Without it, a user could insert a row with someone else's user_id.
The four mistakes that leave data exposed
1. RLS is off
Covered above. The most common and most serious.
2. The policy says "yes" to everyone
using ( true )
A policy with using (true) allows every row to everyone it applies to. RLS is technically "on," and the table is exactly as open as if it were off. AI tools sometimes write this to make an error go away. Search your policies for true.
There are legitimate uses — a public list of blog posts, say — but it should be a deliberate decision for data that is genuinely public.
3. Missing with check on writes
Reads are protected, writes aren't. A user can't see other people's orders, but can create or edit rows as another user. Check every insert and update policy has a with check clause.
4. The secret key is in the frontend
Supabase has two kinds of key. The public one (anon / publishable) is safe in the browser because RLS restricts it. The service role / secret key bypasses RLS entirely. It belongs only in server-side code.
Search your frontend code and environment variables for service_role, sb_secret_, or anything named like a secret with a VITE_ or NEXT_PUBLIC_ prefix. If it's there, it's published to every visitor. Rotate it immediately. (How to Keep API Keys Out of an AI-Built App explains why prefixed variables are public.)
Two less obvious gaps
Views. A database view can bypass the RLS on the tables it reads from, depending on how it was created. If your app queries views, ask whoever set them up (or your AI tool) whether they run with the caller's permissions (security_invoker).
Storage. Uploaded files in Supabase Storage have their own policies. A bucket marked public, or one with permissive policies, can expose every user's uploads. Check them the same way.
Step 3: test it as a stranger
Reading policies is good. Testing them is better. Create two test users, A and B, with some data each. Then:
- Logged in as A, can you see B's rows anywhere in the app?
- Logged in as A, can you edit or delete B's rows?
- Logged out, can you see any private data?
Then test it the way an attacker would: open the browser's developer tools, Network tab, and look at the requests your app makes to Supabase. They include the URL and the public key. Anyone can replay those requests with different filters — for example, removing the user_id filter from a query. RLS should make that return only your own rows, or nothing.
If you're not comfortable doing that, ask a developer to spend an hour on it. It's the single most valuable hour of security review an AI-built Supabase app can get.
Using your AI tool to help
AI tools are good at explaining policies and writing missing ones — if you ask precisely:
List every table in the public schema, whether RLS is enabled, and every policy on it, in plain English. Which tables have no policies, or a policy that uses
true? Which insert or update policies are missing awith checkclause? Is a service role or secret key referenced anywhere in frontend code?
Then verify the answers in the dashboard yourself.
The checklist
- RLS enabled on every table in
public - Every table has policies for the operations the app uses
- No
using (true)except on deliberately public data - Every
insert/updatepolicy haswith check - No service role / secret key in frontend code
- Storage bucket policies reviewed
- Tested with two users and with no login
EasySpawn runs apps with a real backend and a managed Postgres database, so data access goes through server code you control rather than depending on database policies alone — with daily backups and SSL included. See how it works for AI-built apps or join the waitlist.
Related: How to Deploy a Lovable App to Production · How to Add Login to an AI-Built App · What Is a JWT? · Supabase vs Firebase
Keep reading
What Is CRUD? The Four Operations Behind Almost Every App
Create, Read, Update, Delete: most app features are some combination of these four. What CRUD means, how it maps to SQL and HTTP methods, what a CRUD API looks like, and the details — validation, permissions, pagination — that separate a demo from a real app.
What Is a JWT? JSON Web Tokens Explained Simply
Supabase, Firebase, Auth0, and Clerk all hand your app JWTs. What a JSON Web Token is, its three parts, why anyone can read it but nobody can forge it, how apps use it for login, and the mistakes AI-generated code makes with tokens.