Sentrail

Supabase Row Level Security: how it works and the 5 mistakes that leak your data

By SentrailPublished July 31, 2026Updated July 31, 2026 7 min read

Supabase Row Level Security: how it works and the 5 mistakes that leak your data

Row Level Security (RLS) is the Postgres feature that decides which rows each user is allowed to read or write through Supabase's API. If RLS is off on a table, your public anon key can read and write every row in it. That key ships inside your app's JavaScript, where anyone can open dev tools and copy it. RLS is the thing that keeps your database private, and by default it is not always on.

That last part is where most people get burned. You build an app with Lovable or Bolt or Cursor, it works, you ship it, and you never find out that one of your tables is readable by the entire internet until someone tells you. Usually not politely.

Here's what's actually going on, and how to check your own project in about ten minutes.

Why isn't my anon key a secret?

Because it was never meant to be one. The Supabase anon key (the anon public key) is designed to sit in your frontend and travel to every visitor's browser. Anyone using your app already has it. You can find it in the network tab of any Supabase site in a few seconds.

So the key does not protect your data. It just identifies which project the request is going to. The actual gate is RLS, running inside Postgres, deciding per request whether that anonymous or logged-in user is allowed to touch a given row.

This trips people up because it inverts the usual mental model. There's no secret to guard. You assume the attacker already has the key, and you rely entirely on your policies to hold the line. If the policies aren't there, the key is a skeleton key.

There is a second key, the service_role key, that bypasses RLS completely. That one is a real secret and belongs only on a server you control. If it's anywhere in your client code or a public repo, nothing below matters until you rotate it.

Does Supabase turn on RLS for me automatically?

Sometimes, and that inconsistency is the whole problem.

If you create a table through the Supabase dashboard's Table Editor, RLS is enabled for you by default. Good.

If you create a table with raw SQL, including the migrations your AI coding tool writes for you, RLS is not enabled unless the SQL says so. A plain CREATE TABLE leaves RLS off. The table is now reachable through the auto-generated API with no row filtering at all.

Most vibe-coded apps end up with a mix. A few tables made in the dashboard, a bunch created by generated migrations. The dashboard ones are fine. The generated ones are a coin flip, because the model doesn't reliably add the ENABLE ROW LEVEL SECURITY line, and even when it does, it often follows up with a policy that allows everything.

How do I find every exposed table in my project?

Run this in the Supabase SQL editor. It lists every table in your public schema that has RLS switched off:

SELECT tablename
FROM pg_tables
WHERE schemaname = 'public'
AND rowsecurity = false;

Every row this returns is a table your anon key can read and write with no restrictions. If the list isn't empty, that's your priority list for the next hour.

Enabling RLS on one of them is a single line:

ALTER TABLE public.your_table ENABLE ROW LEVEL SECURITY;

One warning before you run that across the board: turning on RLS with no policies means deny-all. The table becomes invisible to your app until you add policies. That's safe, but it will break features if you flip it on in production without the matching policies ready. Do it on a branch first.

How do I write a policy that actually protects a table?

A policy is a rule attached to a table for a specific operation. The most common one you'll write says "a user can only see their own rows."

Assume a profiles table with a user_id column that stores the owner's auth ID:

CREATE POLICY "Users can view their own profile"
ON public.profiles
FOR SELECT
USING (auth.uid() = user_id);

auth.uid() returns the ID of the logged-in user making the request. The USING clause runs on every row the query wants to read, and only rows where it evaluates to true come back. A logged-out visitor has no auth.uid(), so they get nothing.

Writes need their own rule, and writes use WITH CHECK instead of USING:

CREATE POLICY "Users can insert their own rows"
ON public.profiles
FOR INSERT
WITH CHECK (auth.uid() = user_id);

The difference matters and it's the source of mistake number four below. USING decides which existing rows a user can see or target. WITH CHECK decides whether the new or updated row they're trying to write is allowed. An UPDATE policy usually needs both, because you're filtering which rows they can change and validating what they're allowed to change them to.

The 5 mistakes that leak data even when RLS is on

Turning RLS on is step one, not the finish line. These are the ones that still expose data.

1. RLS on, but the policy allows everything. The classic generated policy looks like USING (true). It technically has RLS enabled, so it passes a shallow check, but true matches every row for everyone. This is functionally identical to having no protection. Grep your policies for true and read each one.

2. A policy for SELECT but not for the other operations. RLS is per-operation. If you write a read policy and stop, behavior depends on your setup, and people often assume a locked-down read means the table is locked down. Every table needs its operations covered deliberately: SELECT, INSERT, UPDATE, DELETE. Don't leave gaps and hope.

3. The service_role key in client code. It bypasses RLS entirely, so a single leaked service_role key hands over the whole database no matter how good your policies are. It shows up in frontend bundles, in .env files pushed to public repos, and in edge functions that got copy-pasted to the browser. If it's ever been exposed, rotate it, then hunt down why it was there.

4. Confusing USING and WITH CHECK. A frequent bug is an UPDATE policy with a correct USING clause and a missing or wrong WITH CHECK. The result: a user can only select their own rows, but can update one of their rows to reassign ownership to someone else, or write values they should never be able to set. The read looks secure. The write isn't.

5. Testing while logged in as yourself. You click around, everything looks scoped correctly, you ship. But you tested as an authenticated user hitting their own data. You never checked what a logged-out anon request sees, or what user B can pull about user A. Those are the two tests that actually matter, and they're the two nobody runs.

How do I test whether my policies really work?

Test as the roles that matter, not as yourself.

The fastest manual check: grab your anon key, open a REST client, and hit your table's endpoint with only the anon key and no user token. Whatever comes back is what a stranger sees. It should be nothing, or only the rows you deliberately made public.

GET https://<your-project>.supabase.co/rest/v1/profiles
apikey: <your-anon-key>

If that returns real user rows, RLS is not doing its job on that table.

Then test cross-user access. Log in as user A, get their token, and try to request user B's rows. A correct policy returns an empty set. A broken one returns B's data.

Doing this by hand for every table and every operation is tedious, and tedious is exactly the kind of task that gets skipped right before a launch. This is the part Sentrail automates. It connects to your Supabase project read-only, finds the tables with RLS off, flags the USING (true) policies and the missing write rules, and drafts the exact SQL to fix each one so you can review it before applying anything. No changes happen without your say-so.

But whether you use a tool or a REST client and a spare hour, the rule is the same: assume the attacker has your anon key, and check what they can reach.

FAQ

Is the Supabase anon key safe to expose? Yes, it's designed to be public and lives in your frontend. It doesn't protect your data. Your RLS policies do. The service_role key is the one that must stay secret.

Does enabling RLS break my app? It can. RLS with no policies denies all access, so the table goes dark to your app until you add policies. Enable it and add the matching policies together, ideally on a branch before production.

Is RLS enabled by default in Supabase? Only for tables created through the dashboard Table Editor. Tables created with raw SQL or generated migrations have RLS off unless the SQL explicitly enables it.

What's the difference between USING and WITH CHECK? USING filters which existing rows a user can read or target. WITH CHECK validates the new or changed row a user is trying to write. Reads use USING, inserts use WITH CHECK, and updates usually need both.

Can someone read my database if RLS is off? If the table is exposed through the Supabase API and RLS is off, anyone with your anon key can read and write it. That key is public, so treat an RLS-off table as public data.