ShipSafe

Supabase · 9 min · Updated 28 Oct 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

Dashboard-created tables normally enable RLS; SQL-created tables need explicit enabling. API access also requires grants. Review both grants and policies for every exposed table. Enabled RLS without an applicable policy denies ordinary row access by default.

§ 01

How to read your own exposure first

Before the checklist, see what the outside sees. Your anon key is public by design. API access depends on exposed schemas, grants, and row policies. Use the metadata-only queries in our public-key guide to review your own permissions first.

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", ... } ]
Illustrative response, not a real finding. Only test your own app using synthetic records; grants are required as well as disabled RLS.

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. 02Private tables deny access unless an applicable policy allows it. Add policies only for operations your app actually needs.
  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. 07Review views and privileged functions separately. A SECURITY DEFINER function runs with owner privileges and can bypass RLS.
  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. With RLS enabled, missing write policies deny ordinary writes; they do not make writes unrestricted. The dangerous case is a broad write policy that permits changes to another account's rows.

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 while the owner-account control still succeeds. A passive check does not prove that boundary. ShipSafe's full authorized assessment is not available for purchase yet.

§ 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?
Tables created through the Supabase dashboard normally enable RLS. Tables created through SQL need explicit enabling. API access also requires grants. Enabled RLS with no applicable policy denies ordinary row access by default; add scoped policies for the operations your app needs.
Is it safe for the Supabase anon key to be public?
Yes, by design, but access must match your intended permissions. Review grants and table RLS. Ordinary views need caller-security settings such as security_invoker=true on PostgreSQL 15+, or restricted client access. Review privileged functions separately. Secret and service_role keys bypass RLS and belong server-side.
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.

Free Preflight checks your app's public exposures. It does not verify Supabase RLS or account isolation. No signup, no card.

Keep reading