Skip to main content
友田 陽大
Running the app you built with AI
バイブコーディング
セキュリティ
Supabase
データベース
個人開発

What is Supabase "RLS"? — how AI-built apps end up with all their data public, explained without code

"ChatGPT told me to enable RLS but I don't know what that means." This explains what Row Level Security is, and why without it every user's data is readable by anyone — no code reading required. Includes how to check your own project and an instruction to paste into your AI tool.

Published
Reading time
8 min read
Author
友田 陽大
Share

"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":

iduser_idcontent
1Anote with a bank PIN
2Ashopping list
3Bnotes 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_id to 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

  1. Log in to the Supabase dashboard
  2. Open Table Editor in the left menu
  3. 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

  1. Open AuthenticationPolicies
  2. Read each table's policies one at a time
  3. 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 anon key 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.

Frequently asked questions

What is RLS in one sentence?
It is the database-side setting that decides who can see which rows. Picture locking each individual row of a spreadsheet so that 'this row is A's only' and 'this one is B's only'. Because the decision happens in the database rather than in your application code, the same restriction applies no matter which route the query arrives through.
I have a login screen — why is all the data still visible?
Because the login screen is a browser-side concern and the database doesn't know about it. With Supabase, queries go from the browser straight to the database. The anon key used for that is public information, so an attacker can query without ever passing your login screen. The login screen is the front-door lock; RLS is the lock on the safe. If someone comes through the window, the front door doesn't matter.
Is it a problem that the anon key is visible in the browser?
No. The anon key is public by design and being readable in the browser is the normal state. The dangerous one is the service_role key, which bypasses every restriction and makes RLS entirely meaningless if it reaches the browser. So the problem isn't that the anon key is visible — it's that RLS is absent.
Once RLS is enabled, am I safe?
Enabling it isn't enough. RLS denies everything when no policy exists, so the moment you turn it on your app stops working — and it is extremely common to panic and write the condition USING (true) at that point. That means 'everyone may see every row', which produces the same outcome as leaving RLS off. The condition needs to scope to the owner, as in auth.uid() = user_id.
Is restricting reads enough?
No. Writing the read restriction (USING) and forgetting the write restriction (WITH CHECK) is the most common gap, and it lets someone insert rows owned by another user or reassign their own rows to someone else. UPDATE needs both: USING decides which rows you may touch, WITH CHECK decides what you may turn them into.

References

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

“It works. But is it safe to launch?”

Twenty questions, no signup — a pre-launch self-check for the app you built with AI

You don't need to read any code. Answer yes / no / not sure to questions like “can only the person who logged in see their own data?” You get your risks ordered by severity, plus a fix instruction you can paste straight into Claude Code or Cursor. Nothing you type leaves your browser.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

Also worth reading