Users can see other users' data: how to find the cause and fix it
If users can see the data of other users, the server does not check who owns each row. Turn on Row Level Security (RLS), add an owner policy, and test with two accounts. The cause is almost never the user interface. The cause is the database rules or the API route.
What does it mean when users can see other users’ data?
It means the system returns a row to a user who does not own that row. Security teams call this broken access control. The OWASP API Security list names the most common form “broken object level authorization” (BOLA).
The user does not need special skills. Sometimes the leak appears in the normal screen of your app. A list shows rows from every account. Sometimes the user changes one value in a URL or in a request, and the server returns the row of another person.
The rule must not live in the frontend. The user controls the browser. The rule must run in the database or on the server.
Vibe coding tools often cause this problem. The tool builds a page that works for the first test user. The tool does not always add the rule that limits each user to their own rows. A second user then reveals the gap.
What are the five usual causes?
Find your cause first. The fix depends on it.
| Cause | How it looks | Where the fix goes |
|---|---|---|
| RLS is off on a table | Every signed-in user, and often every visitor, reads all rows | The database |
| A policy allows all rows | RLS is on, but the policy says using (true) |
The database |
| A view bypasses RLS | The table is safe, but a view over the table returns all rows | The database |
| The API route has no owner check | The route reads a row by id and does not compare the owner |
The server code |
| A cache stores a private response | One user gets the page that belongs to another user | The cache settings |
The first two causes are the most common in Supabase apps. The fourth cause is the most common in apps with their own API routes. For the fourth cause, read BOLA in AI-generated CRUD code.
How do I confirm that the leak exists?
Use two normal test accounts. Do not use your admin account, because admin rights can hide the problem.
- Create account A and account B in your app.
- Sign in as account A. Create one record, for example a note, an order, or a profile.
- Write down the
idof the record. - Sign in as account B in a different browser profile.
- Open the network tab in DevTools. Find a request that loads the same type of record.
- Copy the request as
curl. Replace theidwith theidfrom account A. - Send the request. If you receive the record of account A, the leak exists.
Warning: Test only on your own app and with your own test accounts. Do not request real user records to confirm the leak.
Also load the list page as account B. If the list shows the record of account A, the table returns all rows.
For Supabase, you can test the table without your frontend. Use the project URL and the public key. You can find both in the browser requests of your app:
curl -s "https://YOUR-PROJECT.supabase.co/rest/v1/notes?select=*&limit=5" \
-H "apikey: YOUR_PUBLIC_KEY" \
-H "Authorization: Bearer TOKEN_OF_ACCOUNT_B"
Replace notes with the name of your table. If the response has rows that belong to account A, then the table has no working owner rule. If you remove the Authorization header and still receive rows, then anonymous visitors can read the table too. That case is more serious. Fix it first.
How do I stop the leak now?
Do the steps in this order. Step 1 stops the leak for most Supabase apps. The other steps remove the cause.
Caution: Step 1 can make your app show empty pages, because RLS with no policy returns no rows. This is the correct state for a short time. Add the policy in step 2 right away.
- Turn on RLS on the leaking table. Open the SQL Editor in the Supabase dashboard and run the command below. The Supabase RLS guide states that after you enable RLS, no data is accessible through the API with a publishable key until you create policies.
- Add an owner policy. See the SQL in the next section.
- Remove broad policies. Delete any policy that has
using (true)unless the table holds public data on purpose. - Fix the views. Set
security_invoker = trueon each view that reads a private table. - Fix the API routes. Add the owner check to each route that reads or changes a row by
id. - Test again with the two accounts. Use the checks from the confirm section.
Run this command for step 1:
alter table public.notes enable row level security;
Run it for each table that holds user data.
What does the owner policy look like?
The table needs a column that holds the owner. Most apps name it user_id. The column must hold the value of auth.uid() for the user who created the row.
If your table has no owner column, add one. Rows that already exist have no owner, so you must fill the column for them before the policy can work.
alter table public.notes
add column if not exists user_id uuid references auth.users (id)
default auth.uid();
Now add one policy for each action. The syntax below follows the Supabase documentation:
create policy "owner can read" on public.notes
for select to authenticated
using ( (select auth.uid()) = user_id );
create policy "owner can insert" on public.notes
for insert to authenticated
with check ( (select auth.uid()) = user_id );
create policy "owner can update" on public.notes
for update to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );
create policy "owner can delete" on public.notes
for delete to authenticated
using ( (select auth.uid()) = user_id );
The two clauses have different jobs. The using clause decides which existing rows the user can see or change. The with check clause decides if the new version of the row is allowed. The documentation explains that with check stops a user from changing user_id so that the row belongs to another person.
The policies apply to the authenticated role only. An anonymous visitor then receives no rows. If a table must be readable by visitors, for example a public product list, write a separate read policy for that table. Do not use using (true) on a table that also holds private data.
For a full tutorial with team access and role rules, read the Supabase RLS guide on this site. This article only shows the owner rule.
What if RLS is on and users still see all rows?
Check these items.
- Look for a broad policy. In the SQL Editor, run
select * from pg_policies where tablename = 'notes';. Look fortruein thequalcolumn. - Look for a view. The Supabase documentation says views bypass RLS by default, because they are usually created with the
postgresuser. In Postgres 15 and later, usesecurity_invoker = true. The syntax iscreate view name with (security_invoker = true) as select .... - Look for the
service_rolekey. The documentation says this key uses a role with thebypassrlsattribute. If your frontend uses it, RLS does not apply. Replace the key with the public key. Then rotate the leaked key. See what to do about a leaked service_role key. - Look for a function with elevated rights. A function marked
security definerruns with the rights of its owner. If it returns rows without an owner check, it leaks them.
How do I fix an API route that leaks rows?
Apps that do not use Supabase directly often have a route like GET /api/orders/:id. The route reads the row by id and returns it. The route does not check who asks.
Warning: A check in the frontend does not fix this. The user can call the route directly with any id.
Make the owner part of the query. Do not load the row first and then compare in a second step. This example uses a generic SQL client:
const user = await getUserFromSession(req); // your auth check
if (!user) return res.status(401).end();
const order = await db.query(
"select * from orders where id = $1 and user_id = $2",
[req.params.id, user.id],
);
if (!order.rows[0]) return res.status(404).end();
return res.json(order.rows[0]);
Note three points in this code:
- The owner
user_idcomes from the session on the server. It never comes from the request body or from the URL. - The query has both conditions. A row of another user returns nothing.
- The route returns
404when no row matches. It does not return403, so the caller cannot learn that the row exists. The OWASP IDOR cheat sheet recommends the same response for a missing row and a forbidden row.
Apply the same rule to update and delete routes. Also apply it to routes that list rows. A list route without an owner filter returns the rows of every user.
What if a cache shows one user the page of another user?
A shared cache can store a response that holds private data. The cache then sends that response to the next visitor who asks for the same URL.
Check the Cache-Control header of your private pages and API routes. Set Cache-Control: private, no-store on routes that return user data. Clear the cache of your CDN or host. Test again with two accounts.
How do I check if someone already read the data?
You cannot prove that nobody read the data, but you can look for signs. Read the logs for the period in which the leak existed.
- Open the Supabase logs. In the dashboard, open Logs. According to the Supabase logs documentation, the explorer queries Postgres and API Gateway logs by default. The retention time depends on your plan.
- Look for unusual request patterns. Look for requests to the leaking table. Look for one client that sends many requests, or requests that step through many
idvalues. - Look for requests without a session. If the table was open to visitors, look for requests that carry only the public key.
- Read your host logs. For custom API routes, read the logs of your host for the affected path.
The logs show which requests arrived. They do not always show which rows the requests returned. If the logs are short or missing, you cannot rule out a read. Then treat the data as read.
If the rows held personal data, write down what the table held and the time period. You will need this information if you must notify users or a regulator.
How do I prevent this problem next time?
Use these rules for every new table and every new route.
- Turn on RLS when you create a table. Make this the first line after
create table. Never leave a table in the public schema without RLS. - Add an owner column with a default. Use
default auth.uid()so that the database sets the owner. The client then cannot choose the owner. - Write the four policies in the same migration. A table with RLS and no policy returns nothing, so you will notice the gap.
- Do not use
using (true)on private data. Search your migrations for it. - Set
security_invoker = trueon views. Do this for every view in an exposed schema. - Keep the
service_rolekey on the server. Never place it in a variable with aVITE_orNEXT_PUBLIC_prefix. Read environment variables security for the rules. - Write a two-account test. The test creates a row with account A and tries to read, change, and delete it with account B. Run the test before each release. The OWASP page for BOLA also advises that you write tests for the authorization mechanism and that you do not deploy changes that fail them.
- Tell your coding tool the rule. Add this line to the project rules: “Every table has RLS and an owner policy.”
Random identifiers do not replace these rules. Fix the owner check first.
When should I get help?
Get help in these cases:
- The leaked table held payment data, health data, or government identifiers. Ask a security engineer and a lawyer now.
- The logs show an unknown client that read many rows. Treat the data as copied.
- You find the
service_rolekey in the browser bundle. Rotate the key first and check what else it could reach. - You do not know which tables are open. A scan of the deployed app lists the tables and routes that return data to the wrong user.
After the fix, run the two-account test one more time on the production URL. Check one list page, one detail page, and one update call. All three must refuse the data of the other account.
Common questions
Is this a bug in the user interface?
Can I fix this with a filter in the frontend code?
Do I need to tell my users?
Will RLS stop my own server code from working?
How do I test that the fix works?
Test access between users
A VibeEval scan calls the API of your deployed app and checks if a table or an endpoint returns rows that belong to other users.
14-day free trial · No credit card · Cancel anytime