Your app's browser code talks to your Supabase database using a key anyone can read in developer tools. Whether that is completely fine or a public copy of your users table comes down to one per-table setting: Row Level Security. This article is the deep dive on that setting for anyone building with AI tools, Cursor, Bolt, v0, Windsurf, or by hand. If you got here because you build with Lovable specifically, the Lovable security checklist walks the same ground in checklist form; this piece goes one level down.
The anon key is public on purpose. RLS is the actual boundary.
Supabase's architecture is unusual if you came from traditional backends: the browser queries the database directly through an auto-generated API, authenticated with the anon key that ships in your frontend bundle. That is not a leak, it is the design. Supabase's own documentation puts it directly: browser access is convenient and secure "as long as you enable RLS", and RLS "must always be enabled" on tables in an exposed schema.
So stop thinking of the key as the lock. The key is the doorbell. Row Level Security policies, small rules attached to each table, are what decide which rows a given request may read or write. A table with RLS off answers every question the anon key asks, from anyone, logged in or not. That single fact is behind the worst vibe-coding incident pattern so far: CVE-2025-48757, where researchers found 170+ AI-built apps exposing user data through tables that simply had no policies.
How tables end up unprotected in AI-assisted projects
Supabase actually has decent defaults here, and it is worth being precise about the gap. Per the docs: tables created with the Table Editor in the dashboard get RLS enabled by default. Tables created in raw SQL or the SQL editor do not; you are expected to enable it yourself.
Now think about how an AI tool creates tables. It does not click through the dashboard. It writes SQL, as migrations or pasted setup scripts, because that is what code generation is. Which lands every AI-created table on the "enable it yourself" path, and whether the AI remembered to add the enable row level security line depends on the tool, the day, and the prompt. In our experience it is the single most common gap in AI-built Supabase apps. Not because anyone did anything unusual; because the default that protects dashboard users never applies to generated SQL.
This also gives you a second place to look besides the dashboard. If your project keeps its SQL migrations in the repo, search them for the phrase enable row level security. Every create table in a file with no matching enable line is a suspect, and the search takes seconds.
Finding the gaps, then proving them
The dashboard check takes two minutes: open the Table Editor and look for the "RLS disabled" warning badge on each table in the sidebar. Zero warnings: you are most of the way there, and the testing step below is a formality worth doing once anyway.
But do not stop at the badge, because a table can have RLS on and a policy that allows more than you think. The honest test is to ask the database the way an outsider would: with the anon key and no login. Take your project URL and anon key from the dashboard's API settings and run, in a terminal:
curl 'https://<PROJECT_REF>.supabase.co/rest/v1/<table>?select=*' -H "apikey: <ANON_KEY>"
That is the documented shape of Supabase's REST API, and it is exactly what a stranger can run against your project. If a private table returns rows, you have your answer. If it returns an empty array, the boundary held. Run it against each table that holds anything you would not put on a billboard.
Prefer JavaScript? The equivalent through supabase-js is await supabase.from('table').select() run in a context with no signed-in user, a fresh incognito console on your own site works. Same test, same meaning: whatever comes back is what the whole internet can read.
The three policy shapes almost every app needs
Policies are short SQL, and most apps only ever need three shapes. Each example below is adapted from the Supabase RLS docs, with the plain-English version first. Enabling RLS on a table is one line: alter table public.profiles enable row level security;
Owner-only rows
"Users can see and change their own stuff, and nobody else's." The shape for profiles, orders, messages, settings. Reading:
create policy "Users see own rows" on profiles for select using ( (select auth.uid()) = user_id );
Writing gets the same comparison in a with check clause, so users cannot insert rows that claim to belong to someone else:
create policy "Users insert own rows" on profiles for insert to authenticated with check ( (select auth.uid()) = user_id );
Public-read, owner-write
"Anyone can read this, only the author can change it." The shape for published posts, public profiles, product listings. The read policy is deliberately open:
create policy "Public rows are visible to everyone" on posts for select to anon using ( true );
Pair it with the owner-only insert and update policies from the first shape. The using ( true ) is doing exactly what it looks like, and that is fine when it is a decision instead of an accident.
Signed-in users only
"You have to be logged in, but any member can see it." The shape for community content and shared workspaces: the owner-only policies above with the ownership comparison dropped and to authenticated kept. For true admin-only access, the docs point at checking claims from auth.jwt(); that is real but fiddlier, and worth reading the docs page for rather than pasting from anywhere, including here.
The service_role key: the one that must never ship
Supabase also issues service keys that bypass RLS entirely, for server-side admin work. The docs are unambiguous: they "should never be used in the browser or exposed to customers." A service key in frontend code turns every policy you just wrote into decoration. Search your repo for service_role; if it appears anywhere a browser loads, rotate the key in the dashboard and move that logic to a server. If it was also committed to a public repo at some point, treat it as leaked and follow the leaked key runbook.
The fix, delegated properly
You can write every policy by hand with the shapes above. Or you can hand the audit to the AI tool that created the tables in the first place, which has a certain justice to it. This prompt is tool-agnostic; the last instruction matters most, because a policy the AI guessed at is a policy you have not actually decided:
Audit the Row Level Security setup of my Supabase database. For every table:
1. Report whether RLS is enabled. Enable it on any table where it is off, using: alter table <schema>.<table> enable row level security;
2. Look at how the app actually uses the table, then write policies that match: owner-only if rows belong to a user (compare auth.uid() to the owner column), public-read plus owner-write if content is meant to be visible to everyone, signed-in-only if it should be limited to authenticated users.
3. If you believe a table is intentionally public, say so explicitly and give it a deliberate read-only policy rather than leaving RLS off.
4. Summarize every change in plain English: table, what the policy now allows, and why.
5. List anything you were unsure about instead of guessing. I will decide those cases myself.
Do not use the service role key anywhere in client-side code as a workaround.What a scanner can't catch: policy intent
A scan of your repo can flag code that assumes an open table, find a committed service key, and catch the query patterns that suggest missing policies. What no tool can know is what you meant. "Everyone can read all rows" is the correct policy for a table of published posts and a breach notification waiting to happen for a table of user emails, and the SQL is identical. The dashboard's policy list for each table is short and readable. Read yours once, asking of each line "did I decide this?". That is the one part of RLS that was always going to be your job.
Frequently asked questions
Is it safe that my Supabase anon key is visible in the browser?
Yes, if RLS is enabled on your tables. The anon key is designed to be public; Supabase documents that browser access is secure as long as Row Level Security is on. The key identifies your project, and the policies decide what it can touch. A visible anon key is only a problem when it points at tables with RLS off.
Do I need RLS if my app has no login?
Yes, arguably more. Without auth, every visitor is the anon role, so your policies are the only thing between the internet and each table. A public content table can carry an explicit read-only policy. Anything you write from the app, form submissions, signups, analytics, needs a policy that permits inserts but not reads, or someone can download everything your visitors ever submitted.
Will enabling RLS break my app?
It can, and it is worth being honest about: if your code was working because a table was open, enabling RLS without a policy blocks that access, and the feature stops working. That is the boundary doing its job. Enable RLS, add the policy that matches how the feature actually uses the table, then click through the app logged in and logged out. Budget for a round of that, not for zero friction.
What is the difference between the anon key and the service_role key?
The anon key is public and every request made with it passes through RLS policies. The service role key bypasses RLS entirely, which is what makes it useful for server-side admin tasks and dangerous everywhere else. Supabase says plainly that service keys should never be used in the browser or exposed to customers. If yours ever shipped in frontend code, rotate it.
RLS is one of those settings where the work is genuinely small once you can see it: a badge to check, a curl command to prove it, three policy shapes to know. The full security checklist covers everything beyond the database. For this part, run the check, write the policies on purpose, and make sure it's actually done.