Supabase Row Level Security: A 30-Minute Check for AI-Built Apps
Check in 30 minutes whether strangers can read or change your Supabase data, fix the policy mistakes AI tools make, and copy safe RLS patterns.
Your app was built in a weekend with Lovable, Bolt, Cursor or v0, it runs on Supabase, and real people are starting to sign up. The question that should keep you up at night is simple: can a stranger read or change data that isn't theirs? This guide walks you through a 30-minute Supabase row level security check you can run on your own project, explains the policy mistakes AI code generators make most often, and gives you safe patterns to copy.
You don't need to be a Postgres expert. You need access to your Supabase dashboard, a terminal or browser console, and two test accounts.
Why the anon key in your app is public by design
Open your deployed app, view the page source or the network tab, and you will find your Supabase project URL and a key. Many founders panic at this point and assume the key has leaked. It hasn't. The anon key (newer projects may call it a publishable key) is meant to live in the browser. Every visitor gets it, because the frontend talks to Supabase directly.
What the key does is identify your project and let requests in with the lowest level of trust: the anon role for visitors who are not logged in, or the authenticated role once a user signs in. It does not decide what those requests may see. That decision is made inside the database, by row level security.
So the real question is never "is my anon key exposed?" but "what can someone do with it?". If row level security is off on a table, or its policies are too open, the answer may be: read every row, or change it.
There is one key that must never reach the browser: the service role key (or secret key on newer projects). It bypasses row level security completely. If you find it in your frontend code, in an environment variable with a VITE_, NEXT_PUBLIC_ or similar public prefix, or in your Git history, treat it as compromised: rotate it in the dashboard first, then fix the code. Our post on the March 2026 Axios supply chain attack shows how quickly secrets that end up in the wrong place get abused.
How Supabase row level security actually protects your data
Row level security (RLS) is a Postgres feature. Once it is enabled on a table, every query through the Supabase API is filtered by policies you define. A table with RLS enabled and no policies returns nothing to the anon and authenticated roles. That "deny by default" behavior is exactly what you want.
Each policy answers one question for one kind of operation:
| Operation | Clause | What it checks |
|---|---|---|
| SELECT | using | Which existing rows the user may see |
| INSERT | with check | Which new rows the user may create |
| UPDATE | using and with check | Which rows the user may change, and what they may change them into |
| DELETE | using | Which rows the user may remove |
Inside a policy, auth.uid() returns the ID of the logged-in user, taken from their session token. A typical owner policy compares that ID with a user_id column on the row. Because the database does the check, it doesn't matter what the frontend code says or whether someone calls the API directly with curl.
Two consequences follow. First, filters in your frontend code, such as .eq('user_id', user.id), are a convenience, not security. Anyone can remove them. Second, every table in an exposed schema (usually public) needs RLS enabled and deliberate policies for each operation it should allow.
A 30-minute self-check: can a stranger read or change your tables?
Run these steps only against your own project and your own test data. Ideally use a staging copy; if you only have production, read before you write and use test accounts.
Step 1: List tables without RLS (5 minutes)
Open the SQL editor in the Supabase dashboard and run:
select tablename, rowsecurity
from pg_tables
where schemaname = 'public'
order by rowsecurity, tablename;
Every row with rowsecurity = false is a table that anyone with your anon key can query freely, unless you have removed the schema from the API. The dashboard also flags tables without RLS and includes a security advisor that lists common problems. Run it and read every warning, not just the red ones.
Step 2: Read the policies you actually have (10 minutes)
select tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public'
order by tablename, cmd;
Go through the result table by table and ask three questions:
- Does any policy use
trueas its condition? That means "everyone covered by this policy, every row". - Which roles does it apply to?
{public}includes anonymous visitors. - Does every UPDATE policy have a
with_check, and does every INSERT policy check ownership?
Write down anything you can't explain in one sentence. That list is your fix list.
Step 3: Try it as a stranger (5 minutes)
From a terminal, call the API with only your public key, exactly as an attacker would:
curl "https://YOUR-PROJECT.supabase.co/rest/v1/profiles?select=*&limit=5" \
-H "apikey: YOUR_PUBLIC_KEY" \
-H "Authorization: Bearer YOUR_PUBLIC_KEY"
Replace profiles with each table name. An empty array [] for private tables is good. Rows coming back that a logged-out visitor should not see is a finding. If your project uses the newer publishable key format, check the API settings in your dashboard for the exact headers it expects.
Step 4: Try it as the wrong user (10 minutes)
Create two test accounts, A and B. Log in as A and create some data: a profile, an order, a document. Then log in as B, open the browser console on your app and use the Supabase client your app already loads:
// As user B: can I see A's rows?
await supabase.from('orders').select('*')
// Can I change a row that belongs to A?
await supabase.from('orders').update({ status: 'cancelled' }).eq('id', 'ID_OF_A_ORDER')
// Can I give myself a higher role?
await supabase.from('profiles').update({ role: 'admin' }).eq('id', 'MY_OWN_ID')
If B sees A's rows, changes them, or promotes itself, you have found the most common and most serious kind of data exposure in AI-built apps.
The policy mistakes AI code generators make most often
AI coding tools are good at getting an app to work. When a query fails because of RLS, the fastest way to make the error disappear is to loosen the policy, and that is often what happens. Patterns we see again and again:
- RLS never enabled. Tables created through SQL migrations don't always get RLS switched on automatically. The app works, because everything is readable.
using (true)to fix an error. A policy that was meant to be temporary becomes permanent. Sometimes it applies to all roles, including anonymous visitors.- SELECT protected, writes open. Read access is restricted to the owner, but INSERT or UPDATE policies don't check ownership.
- UPDATE without
with check. The user may only update their own row, but nothing stops them from settinguser_idto someone else's ID, or settingroletoadmin. - Roles stored where users can edit them. An
is_admincolumn in theprofilestable, combined with a policy that lets users update their own profile, is a self-service admin button. The same applies to user metadata in the session token, which users can change themselves. - Security only in the frontend. The admin page is hidden in the UI, but the tables behind it are readable by any logged-in user.
- The service role key in the browser. Usually added to "fix" a permission error. It makes every policy irrelevant.
None of these break the app in testing, which is why they survive until launch. If you want more context on where AI coding tools shine and where they need supervision, see our overview of AI coding agents and development platforms.
Safe policy patterns for common app types
These examples assume a user_id uuid column that references the user. Adapt names to your schema and test every change with the two-account check above. The official Supabase row level security guide covers the details.
Personal data: only the owner
alter table notes enable row level security;
create policy "Owner can read" on notes
for select to authenticated
using ((select auth.uid()) = user_id);
create policy "Owner can insert" on notes
for insert to authenticated
with check ((select auth.uid()) = user_id);
create policy "Owner can update" on notes
for update to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);
create policy "Owner can delete" on notes
for delete to authenticated
using ((select auth.uid()) = user_id);
Setting user_id to default to auth.uid() saves the frontend from sending it at all. Wrapping auth.uid() in a select is a common recommendation for performance on larger tables.
Teams and multi-tenant apps
Keep membership in its own table, for example team_members(team_id, user_id, role), and check it in the policy:
create policy "Members can read team projects" on projects
for select to authenticated
using (
exists (
select 1 from team_members m
where m.team_id = projects.team_id
and m.user_id = (select auth.uid())
)
);
Make sure users cannot insert themselves into team_members for a team they don't belong to. That table needs its own careful policies, usually allowing only team admins to add members.
Public content with private drafts
create policy "Anyone can read published posts" on posts
for select to anon, authenticated
using (published = true);
create policy "Authors can read their drafts" on posts
for select to authenticated
using ((select auth.uid()) = author_id);
Writes stay owner-only, as in the first pattern.
Admin roles
Store roles in a table that normal users cannot write to, or in app metadata set only from the server. Never base an admin check on a column or metadata field the user can edit.
Storage buckets and functions: the parts people forget
Many apps get their table policies right and then leak files.
- Storage buckets. A public bucket means anyone with the file URL can download the file. That is fine for product images and wrong for invoices, ID documents or contracts. Private buckets are controlled by policies on storage objects, often based on a folder named after the user ID. The Supabase storage access control docs show the patterns.
- Views. A view can return rows from tables the user shouldn't see, because by default it runs with the rights of its owner. On current Postgres versions you can create views with
security_invoker = trueso the caller's policies apply. - Database functions. Functions marked
security definerrun with the rights of their creator and skip RLS. If they are callable through the API, check every input and add your own ownership checks inside. - Edge functions. They often use the service role key on the server, which is fine, as long as the function first verifies who is calling and what they are allowed to do.
Add these to your fix list from Step 2.
Frequently asked questions
Is it safe that my anon key is visible in the browser?
Yes, that is how Supabase is designed. Your protection comes from row level security and policies, not from hiding the key. The service role or secret key is the one that must stay on the server.
Will enabling RLS break my app?
It may, briefly. With RLS on and no policies, queries return empty results. That is a good sign: it shows you exactly which policies you need. Add them one by one and retest.
My app only talks to Supabase through my own backend. Do I still need RLS?
If your backend uses the service role key and the public key never reaches the browser, RLS matters less for that path. Many teams still enable it as a second layer, because a frontend change or a new feature can expose tables later.
How often should I repeat this check?
After every larger change to your data model, and before every launch or marketing push. AI tools tend to add tables and policies quickly, so a check that was clean last month may not be clean today.
Can I ask an AI tool to fix my policies?
You can, and it often helps with the SQL. Then run the two-account test yourself. A policy that makes errors disappear is not the same as a policy that protects your data.
When to get a second pair of eyes
The self-check above catches the most common problems, and many teams can fix what they find in an afternoon. It gets harder when your app has teams, roles, payments, file uploads, or data from customers in Germany, Austria, Switzerland or Thailand where privacy rules apply. AI tools are also changing how web apps get built, which means more apps reach real users before anyone experienced has looked at the database rules.
That is what our Vibe Code Check is for: a fixed-price security and code review of apps built with Cursor, Lovable, Bolt, v0, Replit or Claude Code. We check secrets, login and permissions, database access rules including RLS and storage buckets, input handling and dependencies, and we try to read and change data that isn't ours, the same way you did above but across the whole app. The Check covers up to 8 screens with a report in 5 business days, and fixing is always quoted separately, so you decide what happens next.