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.
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.
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", ... } ]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.
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.
- 01RLS is enabled on every table exposed through the API, not just the ones you remembered.
- 02Private tables deny access unless an applicable policy allows it. Add policies only for operations your app actually needs.
- 03Read policies scope rows to the owner, typically
auth.uid() = user_id, nevertrue. - 04Write policies (
insert,update,delete) exist and are scoped separately. A read policy does not restrict writes. - 05Insert policies use a
WITH CHECKclause so a user cannot create a row owned by someone else. - 06No policy trusts a client-sent value like a
user_idin the request body to decide ownership. Useauth.uid(). - 07Review views and privileged functions separately. A
SECURITY DEFINERfunction runs with owner privileges and can bypass RLS. - 08The service-role key is never in your frontend, your JavaScript bundle or a
NEXT_PUBLIC_variable. It bypasses RLS entirely. - 09Storage buckets holding user files are private, with policies on the
storage.objectstable, not public. - 10You tested the result with two real accounts: account A cannot read, edit or delete account B's rows through the API.
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.
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.
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.