Supabase hands your database a public URL and a key that ships in your browser bundle. That is by design, and it is only safe because of row-level security. RLS is the thing standing between “users can read their own data” and “anyone can read everyone’s data”, and it is off until you turn it on. Here is how to write policies that hold, and the two mistakes that quietly undo them.
The model, in one paragraph
With RLS enabled on a table, every query is filtered by a policy you write. No policy for an operation means nobody may do it — the default is deny, which is the right way round. Policies are written as conditions, and the useful ones compare a column to the current user. “You may read rows whose owner is you” is a complete security model for most tables and takes one line.
Enable it per table. Forgetting one table is the most common way a Supabase project leaks, because nothing in the application will behave differently and nothing will warn you.
Write policies against relationships, not repeated conditions
Real schemas have tables that are only reachable through another one — comments belong to a post, which belongs to a user. Restating that chain in every policy is how they drift apart. Put the question in a function and call it: “does the current user own the product this row belongs to?” Then the rule exists once, and fixing it fixes every table that asks.
Those helper functions usually need to see rows the caller cannot, so they run with elevated rights — which makes them the most dangerous objects in your schema. Pin their search path explicitly. Without it, someone who can create a schema can shadow a table name and change what your function reads.
The asymmetry worth knowing
The most useful pattern is a table anyone may write to and only you may read. A feedback form, a waitlist, an event log. Write freely, read as the owner. You get that with an insert policy that permits everyone, and deliberately no select policy at all — the absence is what denies reads, so it wants a comment saying it was left out on purpose, or someone helpful will add one back.
Reading that data then happens with the service-role key from your server, which bypasses RLS entirely. That key must never reach the browser. It is the one credential in a Supabase project with no safety net.
A row policy is not a column policy
This is the mistake worth the whole article. A policy decides which rows someone may touch. It says nothing about which columns. So “users may update their own row” permits a user to update every field in it — including the ones your server derives. Credit balances. Plan tiers. Anis_admin flag.
The fix is grants, which are a different mechanism from policies: revoke the blanket permission and grant only the specific columns a client is allowed to name. Do it for inserts too, or a visitor can set the primary key and the timestamps on rows they create.
Worth internalising, because the policy looks completely correct while this hole is open, and it is invisible in every code review that only reads the policy.
Test it as the anonymous role
Reading the policy is not testing it. Switch to the anonymous role in the SQL editor and try the things you believe are impossible: select from the table, insert a row naming a column you did not grant, write a value the constraints should reject. You want the errors. A policy you have not seen refuse something is a policy you are hoping about.
One gotcha while you do: if you revoke everything and forget to grant anything back, the API does not report a permission error. It reports that the table does not exist, because a role with no privileges cannot see it. That message sends people looking for a failed migration for an hour.
Add constraints as well as policies
Policies control who. Constraints control what. On any table an anonymous visitor can write to, add the boring checks — length limits, a format, an allowed set of values — because they are the only validation that survives someone posting directly to the API instead of using your form. Your form’s validation protects your form. The constraints protect the table.