Example finding How it works Coverage PENTEST METHODOLOGY DOCS PRICING FAQ MCP CONTACT LOG IN SIGN UP →

Users can see other users' data: how to find the cause and fix it

FIX / DATA LEAK BETWEEN USERS / 9 MIN READ

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.

  1. Create account A and account B in your app.
  2. Sign in as account A. Create one record, for example a note, an order, or a profile.
  3. Write down the id of the record.
  4. Sign in as account B in a different browser profile.
  5. Open the network tab in DevTools. Find a request that loads the same type of record.
  6. Copy the request as curl. Replace the id with the id from account A.
  7. 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.

  1. 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.
  2. Add an owner policy. See the SQL in the next section.
  3. Remove broad policies. Delete any policy that has using (true) unless the table holds public data on purpose.
  4. Fix the views. Set security_invoker = true on each view that reads a private table.
  5. Fix the API routes. Add the owner check to each route that reads or changes a row by id.
  6. 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.

  1. Look for a broad policy. In the SQL Editor, run select * from pg_policies where tablename = 'notes';. Look for true in the qual column.
  2. Look for a view. The Supabase documentation says views bypass RLS by default, because they are usually created with the postgres user. In Postgres 15 and later, use security_invoker = true. The syntax is create view name with (security_invoker = true) as select ....
  3. Look for the service_role key. The documentation says this key uses a role with the bypassrls attribute. 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.
  4. Look for a function with elevated rights. A function marked security definer runs 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_id comes 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 404 when no row matches. It does not return 403, 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.

  1. 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.
  2. 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 id values.
  3. Look for requests without a session. If the table was open to visitors, look for requests that carry only the public key.
  4. 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.

  1. 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.
  2. 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.
  3. Write the four policies in the same migration. A table with RLS and no policy returns nothing, so you will notice the gap.
  4. Do not use using (true) on private data. Search your migrations for it.
  5. Set security_invoker = true on views. Do this for every view in an exposed schema.
  6. Keep the service_role key on the server. Never place it in a variable with a VITE_ or NEXT_PUBLIC_ prefix. Read environment variables security for the rules.
  7. 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.
  8. 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_role key 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?
Almost never. The interface only shows what the server sends. If the server sends the rows of other users, the server is the cause.
Can I fix this with a filter in the frontend code?
No. A frontend filter hides the rows on the screen, but the browser still receives them. Anyone can call the API without your frontend.
Do I need to tell my users?
Maybe. If the rows held personal data and you cannot prove that nobody read them, local law can require a notice. Ask a lawyer who knows your region.
Will RLS stop my own server code from working?
No. Server code that uses the service_role key bypasses RLS. This is why that key must never reach the browser.
How do I test that the fix works?
Use two normal accounts. Create a row with account A. Then request that row with the token of account B. The result must be empty or an error.

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

SCAN YOUR APP →