ShipSafe

Supabase · 9 min · Updated 26 Aug 2026

Supabase RLS checklist for before you launch

Row Level Security is what stops one customer reading another's rows. Ten checks to run before you launch.

Supabase gives you a Postgres database that your frontend talks to directly, using a public key that ships in your app. That design is fast, and it means one control decides whether a customer can read only their own rows or everyone's: Row Level Security, or RLS.

The one thing to know

RLS is off by default on every new table. A table with RLS disabled, exposed through the API, is readable by anyone with your public anon key — which is in your JavaScript, so anyone at all. Turning RLS on with no policy does the opposite: it denies everyone. You need both the switch and a policy.

§ 01

How to read your own exposure first

Before the checklist, see what the outside sees. Your anon key is public by design, so anyone can query your REST endpoint. A table without RLS answers that query in full.

GET /rest/v1/profiles?select=* HTTP/1.1
Host: <your-project>.supabase.co
apikey: <your public anon key, taken from your JS bundle>

HTTP/1.1 200 OK
[ { "id": 1, "email": "a-real-customer@example.com", ... } ]
Anyone can run this against a table that has RLS disabled. If rows come back, the table is world-readable.

This is not a trick or a private key leak. The anon key is meant to be public. RLS is the thing that is supposed to stop the query returning other people's rows.

§ 02

The 10-point RLS checklist

Run this against every table that holds user data before you launch. In the Supabase dashboard, tables are listed under Database, and policies under Authentication, then Policies.

  1. 01RLS is enabled on every table exposed through the API, not just the ones you remembered.
  2. 02Every table with RLS enabled has at least one policy. Enabled with no policy denies all access and breaks your app quietly.
  3. 03Read policies scope rows to the owner, typically auth.uid() = user_id, never true.
  4. 04Write policies (insert, update, delete) exist and are scoped separately. A read policy does not restrict writes.
  5. 05Insert policies use a WITH CHECK clause so a user cannot create a row owned by someone else.
  6. 06No policy trusts a client-sent value like a user_id in the request body to decide ownership. Use auth.uid().
  7. 07Views and functions that read protected tables do not bypass RLS. A SECURITY DEFINER function runs as its owner and skips policies.
  8. 08The service-role key is never in your frontend, your JavaScript bundle or a NEXT_PUBLIC_ variable. It bypasses RLS entirely.
  9. 09Storage buckets holding user files are private, with policies on the storage.objects table, not public.
  10. 10You tested the result with two real accounts: account A cannot read, edit or delete account B's rows through the API.
§ 03

The mistakes coding agents make most

Agents write policies that look right and are not. Three patterns account for most of what turns up in a real audit.

A policy of USING (true). It reads as "allow" and means "allow everyone". Every authenticated user, and often every anonymous one, reads every row.

A read policy with no write policy. Reads are scoped correctly, so a quick test passes, but any user can still update or delete rows because the write path was never restricted.

Trusting the request body. A policy that checks a user_id sent by the client scopes nothing, because the client can send any value. Ownership has to come from auth.uid(), which the client cannot forge.

Why two accounts matter

Testing RLS with one account cannot find these. You have to prove that account A tried to reach account B's specific row and was refused. That is exactly what a Launch Audit does with your test accounts, and why a passive check alone cannot return "Ready to ship" on authorization.

§ 04

After you fix a policy, prove it

A fix to an RLS policy is easy to get subtly wrong, so verify it the same way you found the gap: sign in as account A, request account B's row, and confirm the API now refuses. Then check your own rows still load, because an over-tight policy that denies everyone is a different outage.

This detect-then-retest loop is the whole point: a finding is not fixed because you changed a line, it is fixed when the original request can no longer reach the data.

Questions people ask

Short answers, no hedging

Is Row Level Security on by default in Supabase?
No. RLS is off on every new table you create, and a table with RLS off is readable by anyone holding the anon key, which ships in your app. Enabling RLS with no policy attached denies everything instead, so both halves have to be done: enable it, then write the policy that scopes rows to their owner.
Is it safe for the Supabase anon key to be public?
The anon key is designed to be public and appears in your client bundle on purpose. It is safe only to the extent that RLS is enabled and correctly scoped on every table and view it can reach. The service role key is the opposite: it bypasses RLS entirely and must never leave your server.
How do I test my RLS policies properly?
One account cannot test authorization. Create two accounts, sign in as the first, and request a row that belongs to the second by its identifier. A correct policy refuses. Then confirm your own rows still load, because a policy tight enough to deny everyone is a different kind of outage.
What is the most common RLS mistake in AI-generated code?
Trusting a user identifier sent by the client. A policy that compares a column against a value the browser supplied scopes nothing, because the browser can supply any value. Ownership has to come from auth.uid(), which the client cannot forge.
Run the check on your own app

Stop reading about it. Point us at your URL.

The free Preflight is passive and read-only. It checks your app for exactly the exposures this guide describes and returns a score, a verdict and one finding with its evidence. No signup, no card.

Keep reading