I use a simple fixture: user A owns record A, user B owns record B, and an anonymous client has no session. Run this matrix against staging with harmless records. Write down the expected result before you run it. This makes a failed test harder to rationalize away.
Copy this matrix for each private resource
“Allow” assumes users may manage their own records. Replace each expectation with your actual product rule. Check both the response and the resulting database state. A 2xx response alone does not prove the intended row changed.
| Actor and target | SELECT | INSERT | UPDATE | DELETE |
|---|---|---|---|---|
| No session → A's private row | Deny or empty | Deny | Deny | Deny |
| User A → A's row | Allow | Allow with A as owner | Allow | Allow |
| User A → B's row | Deny or empty | Deny spoofed owner B | Deny | Deny |
| User B → A's row | Deny or empty | Deny spoofed owner A | Deny | Deny |
| Privileged server process | Only what its job requires | Same | Same | Same |
If your app has organizations, add a second tenant. A user in tenant 1 must not see tenant 2's records just because both users share a role. Add admin and support roles separately. That turns “we have RLS” into explicit authorization promises. The approach follows OWASP's authorization regression testing guidance.
When a cell fails
First confirm the database role and user identity the request used. A client can use a publishable or legacy anon key, but a signed-in session changes the effective role to authenticated. Do not test cross-user isolation with a privileged secret or legacy service-role key; those bypass RLS. Supabase explains this in its API-key guide.
Next, check both database gates. Grants decide whether a role can use a table or function. RLS restricts rows for table operations. With RLS enabled and no applicable policy, access is denied by default. With RLS disabled, a broad grant can expose every row. Policies may differ for SELECT, INSERT, UPDATE, and DELETE, including USING and WITH CHECK conditions. See Supabase's RLS guide and API security guide.
Check paths around the table
| Surface | Question | Evidence to keep |
|---|---|---|
| View | Can it return a row the caller cannot read from the table? | Definition, caller role, sanitized response |
| RPC function | Can anonymous or signed-in users run elevated code? | EXECUTE grants, function definition, request and response |
| Storage | Can A read or overwrite B's object? | Bucket policy, object path, request and response |
| App API route | Does the server check the session and owner before using a privileged key? | Route code and two-user HTTP test |
Views may run with their creator's permissions; for PostgreSQL 15 and newer, check whether security-invoker behavior is appropriate. Functions need an explicit review of EXECUTE grants and SECURITY DEFINER code. RLS on a table is not a blanket rule for those paths. See Supabase's view guide.
Keep the test after the fix
Save the fixture IDs and assertions in your test suite. Run them after policy, table, or API-route changes. Reset staging records between runs so an earlier mutation cannot make a later denial look like a pass. Keep allowed and denied cases together: a policy that blocks everything is not a working product, and one that allows everything is not security.
A live scan can probe public reachability and some authenticated paths when you provide test credentials. It cannot prove every tenant, role, and write operation is correct. Use this matrix for those product-specific rules. Our RLS setup guide covers the policy side, and our public-key guide explains why a key in your bundle is not itself an exposure.
Want an outside view of the deployed app? Run a scan and compare the results with your own two-user tests.