"ChatGPT told me to enable RLS. But I don't know what it means, or how to do it."
This article is for getting out of that state. You don't need to be able to read code. Once you understand the mechanism, you can direct an AI to fix it.
1. What RLS is — think in spreadsheets
RLS (Row Level Security) is the database-side setting that decides who can see which rows.
Think of a "row" as one row in a spreadsheet. Say your app has a table called "notes":
| id | user_id | content |
|---|---|---|
| 1 | A | note with a bank PIN |
| 2 | A | shopping list |
| 3 | B | notes on a job search |
With RLS configured, when A is logged in the database returns only rows 1 and 2. Row 3 is treated as if it doesn't exist.
Without RLS, all three rows come back no matter who asks.
The key point is that this decision happens inside the database. That is a different layer of defence from writing "only show A's records" in your application code.
2. Why a login screen doesn't protect you
This is the part that most contradicts intuition.
"The app can't be used without logging in, so surely someone who isn't logged in can't see the data?" — that's the natural assumption.
What actually happens
With a service like Supabase, queries travel from the browser directly to the database, without passing through your application code.
[What you assume]
user → login screen → your app → database
[What is also possible]
attacker ──────────────────────────→ database
The reason it can go direct is that both pieces of information needed to connect are public:
- The database URL — visible in the browser developer tools
- The
anon(anonymous) key — likewise
And this is not abnormal. The anon key is public by design. It was built to be published.
So what provides the safety? RLS does. The anon key is only "permission to talk to the database"; what you can see is decided by RLS. That is the design.
Without RLS, the public key alone fetches everything. The accurate description is not "a key leaked" but "nothing was ever locked."
An analogy
- Login screen = the lock on the building's front door
- RLS = the lock on each room's safe
Lock the front door, but leave a window open and the safes unlocked, and the contents leave the building. In the Supabase model the "window" is open from the start (that is the correct design), which makes the safe lock mandatory.
3. This has actually happened — CVE-2025-48757
This isn't theoretical.
A vulnerability was registered for apps generated by the AI app builder Lovable:
An insufficient database Row-Level Security policy allows remote unauthenticated attackers to read or write to arbitrary database tables of generated sites
Severity: CVSS 9.3 CRITICAL — 9.3 out of 10, the most severe band.
The notable part is the vendor's rebuttal
The vendor filed a dispute against this CVE. The substance of their argument matters.
Their position was that protecting application data is the customer's responsibility.
That reads less like evasion than a factually correct observation. And what it means is this — the responsibility for protecting the data in the app you built is yours.
The expectation that "the AI tool must be handling this automatically" has been officially denied. There is no option but to check it yourself.
4. Enabling it isn't enough — the USING (true) trap
When you enable RLS, your app will usually stop working.
That is correct behaviour. RLS is designed so that with no policy — no grant condition — everything is denied. The moment you enable it, not a single row comes back.
And here is the accident this article most wants to warn you about.
The app stops working, you panic, and you ask the AI to "fix it, the data stopped loading." The AI produces the shortest path back to a working state.
-- What an AI tends to write to "make it work again"
CREATE POLICY "..." ON notes FOR SELECT USING (true);
USING (true) means "always allow" — that is, "everyone may see every row." The app works. And in security terms, the outcome is identical to having RLS disabled.
Because RLS now displays as "enabled", you feel you have verified it. This is the most dangerous pattern of all.
The correct condition
To scope to the owner, you write:
CREATE POLICY "read only my own notes" ON notes
FOR SELECT
USING ( (select auth.uid()) = user_id );
auth.uid() is "the ID of whoever is asking right now." Allow only when it matches the row's user_id — that is what "the owner's rows only" means.
The (select auth.uid()) form is written that way for performance. A bare auth.uid() is evaluated per row and becomes dramatically slow as row counts grow. It is the form recommended in Supabase's own documentation.
5. The most frequent gap — forgetting writes
Writing the read restriction and forgetting the write restriction. This is the gap I see most often in practice.
USING→ "which rows you may see / touch"WITH CHECK→ "what values you may create / change a row into"
With only USING written, this becomes possible:
- Creating a note that specifies someone else's
user_id - Rewriting your own note's
user_idto someone else's, pushing it onto them
Neither is readable, but both are writable, so normal testing never reveals it.
The correct shape
-- Insert: you can only create rows in your own name
CREATE POLICY "insert only my own notes" ON notes
FOR INSERT
WITH CHECK ( (select auth.uid()) = user_id );
-- Update: you may touch only your rows, and they must stay yours
CREATE POLICY "update only my own notes" ON notes
FOR UPDATE
USING ( (select auth.uid()) = user_id )
WITH CHECK ( (select auth.uid()) = user_id );
UPDATE needs both because "which rows you may touch" and "what you may turn them into" are separate questions. With only one, someone can rewrite their own row into someone else's.
6. How to check for yourself
You can do this without reading any code.
Step 1: Check enabled/disabled in the dashboard
- Log in to the Supabase dashboard
- Open Table Editor in the left menu
- Look at the table list
Any table showing a warning badge such as "RLS disabled" or "Unrestricted" is a fully public table.
Step 2: Look at what the policies say
- Open Authentication → Policies
- Read each table's policies one at a time
- Look for conditions (USING / WITH CHECK) that contain only
true
A policy that is only true restricts nothing.
Step 3: Count whether write policies exist
Look for tables that have a SELECT policy but no INSERT / UPDATE policy. If there is none, that operation is either denied (fine) or permitted loosely by another policy (dangerous).
7. The instruction to paste into your AI tool
If you'd rather have an AI do the checking above, paste this as-is.
Read every Supabase migration/SQL file and report the following as a table.
(1) Tables where RLS is not enabled
(2) Tables where RLS is enabled but no policy exists
(3) Tables whose policy condition effectively allows everyone, such as USING (true)
(4) Tables that have a SELECT policy but no WITH CHECK on INSERT / UPDATE
Then write the RLS policy SQL that scopes each table to "only the logged-in
owner", observing the following:
- For performance, write auth.uid() in the form (select auth.uid())
- Always attach WITH CHECK to INSERT and UPDATE
- Attach both USING and WITH CHECK to UPDATE, and write the reason as a SQL comment
- If anywhere bypasses RLS using the service_role key, list those too
I cannot read SQL, so explain in one plain sentence per policy who is being
allowed to do what.
To check whether the policy you wrote actually scopes anything, paste it into the free RLS checker. It runs entirely in the browser, so your SQL is never transmitted anywhere.
8. Common misconceptions
"If I make the table private I don't need RLS." The Supabase API automatically exposes tables in the public schema. Making them private is a separate setting, and the tables your app uses have to be exposed anyway. It is not a substitute for RLS.
"Using the service_role key is easier." It certainly works — because it bypasses RLS entirely. But the moment that key reaches the browser, all data is readable and writable by anyone. AI-generated code reaches for this key precisely because no error appears. A textbook case of the easy path being the most dangerous.
"It's only for me, so it's fine." If it really is only you and the URL isn't published, the risk is limited. But if you're thinking "maybe I'll show people later," there's no guarantee you'll configure it then. Doing it now is cheaper.
"I'll think about it once I have users."
RLS can be added later, but data that has already leaked does not come back. And retrofitting RLS breaks all your existing queries at once, which is exactly the situation that produces a panicked USING (true).
Summary
- RLS is the database-side setting that decides who can see which rows
- With Supabase, queries go from the browser straight to the database, so a login screen is not a defence. The
anonkey being public is normal; the absence of RLS is what's abnormal - Enabling it isn't enough —
USING (true)produces the same outcome as disabled - You need writes (
WITH CHECK) as well as reads (USING) - On where responsibility lies, the tool vendor itself has stated it rests with the user
RLS is where AI-built apps have the most incidents. Which also means closing it removes your single largest risk. The configuration isn't hard — it just gets forgotten.
If you don't yet have the full picture, start from Before you launch the app you built with AI — a complete pre-launch guide for non-engineers, which also covers what else to check.