How to prove one customer can't see another's data
If your app keeps data for many companies (developers call that multi-tenant), one of the worst bugs is one customer seeing another's records. On Supabase, the rules that prevent it live in the database, and your pages can't prove they work, because anyone with a login can skip the pages and ask the database directly. So I built a small two-company app, wrote a test that signs in as each company and goes after the other's data, then broke the rules on purpose to see if the test noticed.
The short version
Test as a second customer, straight against the database's API (the web address your app's code sends its queries to): try to read, change, delete and create the other company's records, then read those records back instead of trusting the response. My test runs 16 checks as company A against B, then the same 16 as B against A. With correct rules all 32 passed; with three one-line mistakes planted, 12 failed, and each failure showed the request that got through.
Published
1 Your pages don't decide what a customer can see
A Supabase app ships a key to every visitor's browser. Supabase says that key is safe to put in a web page because it "only reaches what Row Level Security allows" measured (Supabase's API keys page). Row Level Security, or RLS, is the set of rules in the database that says which rows (records) each signed-in user may read and change.
That means anyone who signs up for your app has everything they need to query your database without your pages: the key and their own login. If your page filters the list to their company but the rules don't, the page is the only thing hiding the rest derived. The simpler version of this, tables anyone at all can read, isn't rare: in September 2026, UpGuard, a security company, reported finding 16,326 Supabase databases with tables open to anyone reported (UpGuard).
2 The 16 checks
My test app has two companies, A and B, one user in each, and three documents per company. The test signs in as A's user and runs these against the database's API, never through a page, then signs in as B's user and runs the same 16 against A's data measured:
- Its own documents (4 checks). A can read all of them, update one, delete one and create one. A test that only proves "no access" would also pass on an app where nobody can do anything, so these come first.
- The other company's documents (9 checks). List everything and look for any B row. Ask for documents filtered to B's company ID. Ask for one of B's documents by its ID. Count B's rows, since a count alone tells A that B's data exists. Update B's document. Move one of A's own documents into B. Delete B's document. Create a document labeled as B's. Overwrite B's document by reusing its ID.
- The tables around them (3 checks). A sees only its own company record. A can't edit its membership to join B. A signed-out visitor reads nothing.
When a check fails, the test prints the exact request that got through and the database's rules for that kind of request, so the fix starts from evidence instead of a guess.
3 A blocked change often looks like a success
Postgres, the database Supabase runs on, handles a blocked change in two different ways. When the rules hide B's rows from A, an update or delete aimed at them is usually skipped quietly: in Postgres's words, "such rows are silently suppressed; no error is reported" measured (Postgres documentation). When A writes values the rules don't allow, such as a new document for company B, or one of A's own documents moved into B, Postgres refuses with an error measured.
So "the update went through without an error" tells you nothing either way derived. Every change check in my test reads the records back afterwards, with an all-access key, and passes only if the other company's documents still exist with their original owner and title, the company's own documents still belong to it, and nothing new landed in the other company.
One thing the rules can't hide is whether an ID is taken. If A tries to create a record with an ID that B already uses, Postgres refuses: a plain create fails on the duplicate ID, and a create-or-update (an "upsert") fails because, in Postgres's words, "the UPDATE path will never be silently avoided" measured (Postgres documentation). An unused ID gives no such error. With long random IDs that's hard to exploit, but it's worth knowing derived.
4 Three mistakes I planted, and what caught them
A test that has never failed hasn't proved anything, so I added three one-line mistakes to the correct rules and ran it again in both directions measured:
- An extra "anyone signed in can read" rule. Postgres joins ordinary (permissive) rules with OR, so one loose rule opens every row to reading, however strict the other ordinary rules are measured (Postgres documentation). Four read checks failed each way (the full list, the company filter, the document ID and the count), and each company could read all 3 of the other's documents.
- An update rule whose check was loosened to "allow anything". Each company moved one of its own documents into the other. One check failed each way.
- A create rule that only checked the user was signed in. Each company wrote a new document into the other. One check failed each way.
That's 12 failures out of 32, 6 each way. Each report listed the rules for that kind of request, with the planted one among them, so the bad rule was easy to spot. With the correct rules put back, all 32 passed again.
5 One "mistake" that isn't one
An update rule has two parts: one picks which rows you may change, the other checks the new values. I first planted an update rule with the second part left out, expecting A to be able to move a document into B. It couldn't. Postgres says that when the check is left out, the first part "will be used both" to pick the rows you may change and to check what you change them to measured (Postgres documentation).
So a missing check is safe as long as the first part checks the company. The real hole is a check that's there and too loose, like mistake 2. If a review flags a missing check, writing it out is still worth doing so the intent is clear, but fix a check that says "true" first derived.
6 What you can check yourself
- In your Supabase project, open Advisors in the sidebar, then Security Advisor. Fix or explain every error it shows, and read every warning too. Its errors include tables with RLS turned off and views that skip RLS; a rule that lets everyone through, like mistake 1, shows up only as a warning ("Security policy allows unrestricted access") measured (Supabase's Advisors page). A view is a saved query that looks like a table. By default it checks the tables underneath as its owner rather than the visitor, and in Supabase that owner usually skips RLS.
- Open the SQL Editor and run
select tablename, policyname, permissive, roles, cmd, qual, with_check from pg_policies where schemaname = 'public';. It only reads; it changes nothing. In each row,rolessays who the rule applies to (anonmeans signed-out visitors),permissivesays whether it opens access or narrows it,qualis the part that picks which rows a rule allows, andwith_checkis the part that checks new values. Look fortrueon its own, and for rules that only check someone is signed in (such asauth.uid() is not null) where they should check the company. - Never test this on your live app. Use a staging copy: a separate test version with its own database, such as a second free Supabase project. Make two test accounts in two different companies.
- If your app shows a record's ID in the page address (like
/documents/123), sign in as company A and open one of company B's addresses. Then go further than the pages: ask whoever built your app, or your AI coding tool, to write a test that signs in as each company in turn, asks Supabase directly for the other's records by ID, tries to update one, and then checks from the other account that nothing changed. - Rerun that test whenever your access rules or tables change. Each of the leaks above came down to one added or changed rule measured.
7 How I got this
On October 21, 2026, I built a small app on my own computer with Next.js (a popular web framework) and Supabase, running Supabase locally (Postgres 17), with two companies, one user each and three documents each. I wrote the 16 checks with Supabase's JavaScript client and ran them in both directions against the correct rules, then against the three planted mistakes, then against the correct rules again. Every count from my test comes from those runs.
It's one small test app, not a customer's. A real app has more tables and user roles, plus file storage, database functions and server code that uses Supabase's all-access key, and this test doesn't cover any of those. Each user here belongs to exactly one company; apps where a user can belong to several organizations and switch between them need extra checks. I quoted Postgres's behavior from its version 18 documentation, read October 21, 2026; my local runs on version 17 behaved the same way.
- Measured
- I counted it myself, or read it on the source that owns the fact (a product's own documentation, say).
- Reported
- A named source says it, and I can't check it myself.
- Derived
- My own reasoning or arithmetic from the two above.
8 Sources
- PostgreSQL 18 documentation, "CREATE POLICY" (read October 21, 2026). www.postgresql.org
- Supabase documentation, "API keys" (undated; read October 21, 2026). supabase.com
- Supabase documentation, "Performance and Security Advisors" (undated; read October 21, 2026). supabase.com
- UpGuard, "Everything Everywhere: Systemic Data Exposure in Supabase Apps", September 24, 2026, updated October 14, 2026. www.upguard.com
9 Want me to look at your app?
The Launch Blocker Check is a private first look at your repo: I don't touch your live app or use a key I find without your written OK.
Launch Blocker Check, $149Or check it yourself first: the security self-check walks through eleven checks, most of them in your Supabase dashboard or your GitHub repository.