How to test Supabase RLS policies (free ways, from inside and outside)

If your app runs on Supabase, Row Level Security (RLS) is the thing standing between your data and anyone who opens DevTools. AI builders like Lovable and Bolt set up Supabase for you, which is great, but it means a lot of people ship apps without ever looking at their policies.

Here's how to actually test them: first from inside Supabase, then from the outside the way a stranger would, then with two real accounts. All free.

Why RLS matters more than you'd think

Your frontend talks to Supabase directly using a public key: the publishable key (sb_publishable_...) or, on older projects, the legacy anon key. That key is meant to be visible. Supabase's own docs describe it as "taped to the front door" and say it "only reaches what Row Level Security allows." (Supabase docs: API keys)

So the whole security model rests on RLS. Supabase is blunt about it: "A table in an exposed schema without RLS is readable and writable by any role with a grant on it. Enable RLS on every table in an exposed schema." (Supabase docs: Row Level Security)

One more thing from the same page that trips people up: adding policies doesn't take grants back. On projects that still grant anon and authenticated every privilege by default, a table protected only by policies can still give anon a path you didn't intend. Supabase recommends revoking those grants.

Step 1: Run the Security Advisor (inside)

Start here. In the Supabase dashboard, open Advisors → Security Advisor. Supabase describes Advisors as "deterministic checks that ship with the platform" that flag issues "such as… misconfigured RLS policies." (Supabase docs: Advisors)

Fix every error. Supabase does note that some findings may be intentional, so read each one against what you actually want public.

Step 2: Read your policies like a stranger would

The quickest way to see every policy in one place is SQL. Open the SQL Editor in the Supabase Dashboard (that's where Supabase's RLS guide tells you to run its SQL: Row Level Security) and run:

-- Which tables in public have RLS on? (pg_tables.rowsecurity)
select tablename, rowsecurity from pg_tables where schemaname = 'public';

-- Every policy, who it applies to, and its using / with check expressions
select tablename, policyname, cmd, roles, qual as using_expr, with_check
from pg_policies where schemaname = 'public';

Both are standard Postgres system views (pg_tables, pg_policies). If you'd rather read your migration files, the same create policy statements are there. For each table, ask:

  1. Is RLS enabled? If not, and the table is in public, assume anyone can read and write it.
  2. Who is the policy for? to anon means signed-out visitors. to authenticated means any signed-in user, not just the owner.
  3. What does using say? using (true) means "every row." That's fine for genuinely public content (a list of announcements) and wrong for anything personal.
  4. Do insert/update policies have with check? Supabase explains that with check "ensures that any new row adheres to the policy constraints, so a user cannot create a row that belongs to someone else."

A safe "own rows only" policy, straight from Supabase's docs:

create policy "Users can view their own profile."
on profiles for select
to authenticated
using ( (select auth.uid()) = user_id );

And for inserts:

create policy "Users can insert their own profile."
on profiles for insert
to authenticated
with check ( (select auth.uid()) = user_id );

Wrapping auth.uid() in (select ...) is the pattern Supabase uses in its examples.

"using" vs "with check" in plain English

This shows up in autocomplete a lot ("supabase rls using vs with check"), so here's the short version:

  • using filters which existing rows you can see or touch (select, update, delete).
  • with check validates rows you're writing (insert, update): the new or changed row must pass.

An update policy usually needs both: using so you can only target your own rows, and with check so you can't change user_id to someone else's.

Step 3: Test from outside, signed out

Now check what actually happens, not just what the policies say.

  1. Open your live app in a private window (signed out).
  2. Open DevTools → Network and use the app normally.
  3. Look for requests to https://<project>.supabase.co/rest/v1/<table>. Each one tells you a table the page reads and with which key.
  4. Click a request and look at the Response. If you see rows a signed-out visitor shouldn't see (emails, orders, messages), that table's RLS or grants are wrong.

You can also query a table directly, the same way Supabase's own API quickstart does: a GET to /rest/v1/<table> with your publishable key in an apikey header (Supabase docs: Build an API route in less than 2 minutes). Their example is a curl command; this is the same request from the browser console, signed out, using the URL and key your site already ships:

const r = await fetch('https://<project>.supabase.co/rest/v1/profiles?select=*&limit=5', {
  headers: { apikey: '<your publishable key>' }
});
console.log(r.status, await r.json());

An empty array or an error is what you want for private tables. Rows mean anyone can read them. Only do this against your own project.

Step 4: The two-account test

Signed-out tests miss the most common bug: any signed-in user can read everyone's data, because the policy says to authenticated using (true).

  1. Create user A and user B.
  2. As A, create some data (a profile, an order, a note).
  3. Sign in as B. In DevTools, find a request that loads B's data and replay it with A's ID (change user_id=eq.<B> to <A>, or change the ID in the URL).
  4. You should get nothing back. Then try to update A's row as B. That should fail too.

Step 5: Automate it with tests (optional)

Supabase's RLS docs point to supabase test db as part of the procedure for securing a table: write SQL tests (pgTAP) that assert what anon and authenticated can and can't do, and run them in CI. It's more setup, but it stops a future AI-generated migration from quietly undoing your policies. (Supabase docs: Row Level Security)

Don't forget Storage and secret keys

  • Storage buckets have their own policies. Check that private buckets aren't marked public and that file paths are scoped to the user.
  • Secret keys (sb_secret_..., or the legacy service_role) bypass RLS completely. Supabase says they must never leave your backend. Search your site's JS for both strings. If either shows up, rotate it right away. (Supabase docs: API keys)
  • Supabase is deprecating the legacy anon and service_role keys by the end of 2026. If an AI assistant tells you to copy a long key starting with eyJ, it's using the old system.

Quick recap

  • Security Advisor: no errors
  • RLS enabled on every table in public
  • Unneeded anon/authenticated grants revoked
  • No using (true) on private tables
  • Insert/update policies have with check
  • Signed-out requests return nothing private
  • User B can't read or edit user A's rows
  • Storage buckets scoped; no secret key in the frontend

An outside check, if you want one

The steps above are the real work, and the Security Advisor ships with Supabase, so start there. If you also want an outside view of your live site, PreFlight runs 65 checks the way any visitor could. Once you verify you own the site, it also checks whether your Supabase tables and storage can be read while signed out. The first scan of each site is free (score plus your top 3 fixes). The full report is a one-time $19 (or $49 for 3 scans, $69 for 5), with a 14-day refund on unused scans.

Verify your site and PreFlight checks your tables from outside