Your app's key is public on purpose
When you build on Supabase, your app usually talks to the database straight from the browser. To do that it carries a key: the anon key, or in newer projects a publishable key. Supabase designed this key to be public. It is in your JavaScript, and anyone who opens your app can read it.
That surprises people, because it sounds like leaving the front door key under the mat. It is safe only because of a second layer that decides what that key is allowed to do. That layer is row level security.
The practical consequence is simple: anyone can take your public key and send requests to your database directly, without using your app's screens at all. Whatever your rules allow, they can do.
What row level security does
Supabase is built on Postgres. Row level security, often shortened to RLS, is a Postgres feature that checks every request against rules you write, called policies, before any row is returned or changed.
With RLS turned on and no policies, a table returns nothing to the public key: everything is closed. Each policy then opens a specific door, for example "a signed-in user may read their own profile" or "anyone may read published blog posts".
With RLS turned off, the rules are not checked at all, and the public key can read, and often change, every row in the table.
How tables end up open
We see the same few patterns again and again:
- RLS was never turned on for a table, often one created with SQL or added late in the project.
- A policy allows everything, written to make an error go away during development and never tightened.
- A policy checks the wrong thing, for example that someone is signed in, rather than that the row belongs to them, so every customer can read every other customer's rows.
- A table was meant to be private but holds a public copy, such as a view or a helper table that repeats data from a protected one.
None of these shows up while you use your own app, because your app only asks for the rows it needs. They show up when someone asks the database directly.
Policies in plain words
A good way to write policies is to say each one out loud first, as a sentence about people and rows, then check the policy says exactly that:
- Who is asking: anyone, any signed-in user, or a particular user.
- What they want to do: read, add, change or delete. Each needs its own thought; being allowed to read does not mean being allowed to change.
- Which rows: usually the ones whose owner column matches the signed-in user's id.
If you cannot say who should see a table, it probably should not be readable by the public key at all. Data only your server needs can stay closed to the browser entirely and be reached through a server-side function instead.
The key that ignores every rule
Supabase also gives you a service role key (or, in newer projects, a secret key). It is meant for your server and it bypasses row level security completely: with it, anyone can read, change or delete every row in every table.
It must never appear in your front end. If it ever has, treat it as leaked: create a new one, update your server-side code, and revoke the old one. A free survey looks for secret keys in your public code, including this one.
How to test it
The question to answer is: what does my database give someone who is not signed in, and what does it give a signed-in user who is not the owner?
- In the Supabase dashboard, check that every table in the public schema has RLS turned on, and read each policy against the sentences you wrote above.
- Supabase's own security advisor in the dashboard flags tables without RLS. It is worth running after every change to your tables.
- Try your app as two different test users and make sure neither can reach the other's data, including by changing an id in the address bar.
Once you have proved you own your site, a verified Shipwardly scan asks each table your app uses how many rows it would show to the public key. It reads only the count, never a row, and only from the Supabase project your own site uses.