Ground Crew blog

Supabase row level security, explained for founders whose app holds customer data

The key everyone can see is not the problem

Every Supabase app ships a key called the anon key in the browser, where anyone can read it. New founders often panic about that, and it is the wrong thing to panic about. The anon key is designed to be public. It identifies your project, and on its own it should be able to do only what an anonymous visitor should be able to do. Whether that is true depends entirely on one setting: row level security, RLS for short, and the policies written under it.

Think of the anon key as the address of your building and RLS as the locks on the apartment doors. Publishing the address is normal. What matters is whether a visitor who walks in can open every door.

What happens when RLS is off

With RLS off on a table, the public key can read the whole table. Not just the signed in user's rows. All of them. Names, email addresses, orders, whatever the table holds. The app looks perfectly safe from the founder's chair, because the interface only ever asks for the current user's data. The database, asked directly, tells a different story, and asking it directly takes about a minute from the browser console on your own home page.

There is a second pattern that survives even when RLS is on. Your app's own API can check that a visitor is logged in without checking that the record they asked for is theirs. Change the ID in the address from your record to the one next to it, and the app hands over somebody else's. Builders call this pattern IDOR. It is common in AI generated code because the generated route checks the session, which is visible work, and skips the ownership check, which is the part that only matters once a second user exists.

Test yours safely, with two accounts

You can run the honest version of this test on your own app without touching anyone's real data. Create two test accounts, A and B, and give A a record B should never see: an order, a document, whatever your app stores. Logged in as B, try to reach A's record the way the app normally loads it, then by changing the ID in the address or the request. Then log out entirely and, using only the public key from your own page source, try a read against the table that holds user data. Logged out, you should get nothing. As B, you should get only B's rows. Anything else is a finding, and it is a serious one, because your customers' trust sits on exactly this.

Why this is not a prompt and done fix

RLS policies are small pieces of logic with sharp edges. A policy can look correct and still leak, because it checks the wrong column or trusts a value the client supplies. The mirror image failure is just as common: a policy written in a hurry locks every user out of their own data, and the founder discovers it from support tickets. So for data access, this post gives you the test and the shape of the fix, not a snippet to paste: enable RLS on every table that holds user data, write policies that scope reads and writes to the row's owner, keep anything cross account on the server side where the service role key belongs, and then run the two account test again. The test is the product. A policy you have not exercised from a second account is a guess.

Verify it: logged out, the public key reads nothing from user tables. Logged in as account B, no route, page, or direct query returns account A's records, and A's record ID typed into B's session comes back empty or refused.

FAQ

I found my anon key in my page source. Have I been hacked?

No. The anon key is public by design. What decides your safety is row level security and its policies. Panic about the service role key instead: that one bypasses every policy and should never reach the browser.

My app has no login. Does RLS still matter?

If the app stores anything submitted by users, yes. Public reads of public content can be fine as a deliberate choice. The finding is when tables holding private records answer anonymous reads by default rather than by decision.

Turning RLS on broke my app. What happened?

The policies did not cover something the app legitimately does, so the database started refusing real work. That is why the fix is policies plus a two account test, in that order, rather than flipping the switch and hoping.

Related reading: Your app ships its keys to every visitor, Paid but no access: how Stripe webhooks quietly break AI built apps

Ground Crew note

Data access is check 01 on the free production audit, and we run the two account test on every audit rather than just reading the policies. The rest of the list is on the post launch checklist, or request the free audit and we will run the test with you.