Supabase RLS policy leaking data between users
Row level security can be on and the anon key can still return an empty array. That only proves an anonymous request is blocked. A policy of auth.role() = 'authenticated', or using (true) for the authenticated role, lets every logged-in user read every row. The client filter .eq('user_id', user.id) does not close that hole. Anyone can send the request without the filter.
What you see
- You log in as yourself and a list shows another customer's notes, invoices, or files. Logging out and requesting the same table with only the anon key returns [] or an error.
- Changing the id in the URL, or calling the REST endpoint with your own access token and no user_id filter, returns someone else's row with status 200.
- Update or delete works the same way: your session can change a row whose owner is a different auth user. The button is hidden. The request is not.
Why this happens
- A select policy that checks only that a user is logged in matches every row.
- The owner check exists in the app code and was never copied into the policy. PostgREST applies the policy, then runs the query. It does not apply the .eq() you forgot.
- A view or a security definer function runs with the owner's rights and skips the caller's policy.
Common causes
The policy checks the role and not the owner
auth.role() = 'authenticated' is true for every signed-in user. using (true) is true for every row. Either one on a select policy is a shared table. An or between a real owner check and one of those expressions disables the owner check.
How to check. In the SQL editor, read qual and with_check from pg_policies for this table. A select policy for a private table should compare auth.uid() to the owner column. A qual of true, or a qual that only calls auth.role(), is the leak.
Insert and update are tight, and select is not
Generated policies are often split. The insert policy uses with check (user_id = auth.uid()) and the select policy was left open so the UI could 'just list rows'. Inserts look safe. Reads are not.
How to check. Compare cmd in pg_policies. SELECT, UPDATE, and DELETE each need an owner expression. UPDATE needs it in both using and with check, or a user can move a row onto someone else.
A view or function bypasses the table policy
A view created with security invoker off uses the view owner's rights and ignores the caller's RLS. A security definer function does the same unless the function body checks auth.uid() itself.
How to check. If the app reads a view, check its options for security_invoker. If it calls an RPC, open the function. definer plus no auth.uid() check is a bypass. The table policies can be correct and the leak still happens.
Fix it
1. See the policy that is actually stored
The Supabase table editor and the SQL catalog can disagree with what a prompt said it created. Read the catalog. qual is the USING expression.
select policyname, cmd, roles, qual, with_check from pg_policies where schemaname = 'public' and tablename = 'notes';
2. Replace a wide select policy with an owner check
Enable RLS, drop the policy whose qual is true or only checks the role, and add a select policy for authenticated that compares the owner column to auth.uid(). Use the owner column you actually store. If that column is not the auth user id, compare auth.uid() to the column that is.
alter table public.notes enable row level security; drop policy if exists "read notes" on public.notes; create policy "read own notes" on public.notes for select to authenticated using (user_id = auth.uid());
3. Use the same check for writes
Select is what leaks the data onto the screen. Update and delete are what let one user change another user's row. Insert has no existing row, so the check belongs in WITH CHECK.
create policy "insert own notes" on public.notes for insert to authenticated with check (user_id = auth.uid()); create policy "update own notes" on public.notes for update to authenticated using (user_id = auth.uid()) with check (user_id = auth.uid()); create policy "delete own notes" on public.notes for delete to authenticated using (user_id = auth.uid());
4. Leave the client filter in place, and do not trust it
Filtering in the query is fine. It is not the control. After the policy change, a request that omits the filter still returns only the caller's rows.
const { data, error } = await supabase
.from("notes")
.select("id, body")
.eq("user_id", user.id);5. Read the table as the other user
Use user B's access token from the session, and the anon key as apikey. This is the request the browser can already make. The service_role key must not be the bearer token. A's row in this response means the policy is still open.
curl "https://YOUR_PROJECT.supabase.co/rest/v1/notes?select=id,body" \ -H "apikey: YOUR_ANON_KEY" \ -H "Authorization: Bearer USER_B_ACCESS_TOKEN"
6. Stop a view from bypassing the table
security_invoker, added in Postgres 15, makes the view run as the caller so the table policies apply. Supabase projects are on Postgres 15 or newer. A security definer function needs its own auth.uid() predicate in the function body. There is no table policy that can see inside it.
alter view public.notes_view set (security_invoker = on);
How to verify
- Run the pg_policies query again. Every SELECT, UPDATE, and DELETE policy on the private table should mention the owner column and auth.uid(). None of the qual values should be true.
- Create two users in this project. As user A, insert one row. Copy user B's access token from the session (the user JWT, role authenticated). Do not use the service_role key for this test. That key bypasses RLS, so a successful read tells you nothing about the policy.
- Request the table as user B with the anon key as apikey and user B's access token as Authorization. The response must not include A's row. An empty array is the right result when B has no rows of their own.
- PATCH and DELETE user A's id with user B's token. Both should change zero rows. Then read the row again as user A and confirm it is unchanged.
How the daily check catches it
ShipWatch does not need your service_role key. It uses the two test accounts you stored. User B opening user A's page is the failure, whether the cause is this policy or a route that never checks the owner.
What ShipWatch looks for
- The public scan sends the anon key and no user session. An empty result there does not mean logged-in users are isolated.
- The daily check logs in as test user A, collects private URLs, then logs in as test user B and opens them. If B can read A's sentences, the run fails and names the path.
A public look, before the daily check
The free scan requests tables with the anon key only. A policy that leaks between logged-in users can pass that scan. The free roast is the same kind of public look, with a grade and a fix prompt. Run either one against the URL you deployed. The daily check is the run that logs in as two users.
Prompt to paste
Logged-in user B can read user A's rows, and a request with only the anon key returns nothing. Replace select policies that use auth.role() = 'authenticated' or using (true) with using (user_id = auth.uid()). Add the same owner check to insert, update, and delete. Do not treat a client-side .eq filter as the access check. Verify with user B's access token that A's row is absent. Do not verify with the service_role key.