A user can see another user's data (IDOR): find and fix it
How to test your own app for IDOR with two accounts, why AI-built apps often have it, and how to fix it with server-side ownership checks and RLS policies.
Leer en español →
When one user can open another user's records by changing an ID in a URL or a request, your app has an IDOR (insecure direct object reference): the server returns whatever ID it is given without checking who is asking. The fix is to make the server or database decide ownership from the signed-in session on every read and write, for example with Supabase Row Level Security policies that compare auth.uid() to the row's owner, instead of trusting the ID the browser sends or the fact that the interface hides the link.
This guide shows how to test your own app with two accounts, why apps built with AI tools are prone to this, how the fix looks in Supabase and in a custom API, and what to do if real users were affected.
What does IDOR actually look like?
A user opens /invoices/1042 and sees their invoice. They change the address to /invoices/1041 and see someone else's. Or the app loads data with a request like GET /rest/v1/orders?user_id=eq.abc123, and swapping in a different user_id returns that person's orders.
Nothing about this needs special tools. The data is one edited request away, which is why OWASP ranks broken access control as the number one web risk.
It is not only about reading. The same flaw often lets a user update or delete someone else's records, or create records that belong to another account.
Why do AI-built apps have this problem so often?
Because the code that makes the preview look right and the code that keeps data private are in different places, and the AI usually writes only the first.
- The frontend filters with
.eq('user_id', user.id), so each user sees only their own rows on screen. But that filter runs in the browser, and the user controls the browser. - Supabase tables were created with Row Level Security off, or with a broad policy such as
using (true)added to "fix" an empty list. - A custom API endpoint takes
userIdfrom the request body or the URL instead of from the session token. - Admin-only data lives in the same table as user data, with the difference enforced only by hiding the admin page.
Random-looking IDs (UUIDs) make guessing harder, but they are not a fix. IDs leak through shared links, emails, logs and screenshots.
How do I test my own app for it?
Use two test accounts you own, with different data in each. Do this in a test environment if you have one.
- Sign in as user A and create a record of each kind your app has (a project, an invoice, a message, a file).
- Open the browser's developer tools, go to the Network tab and note the requests that load or change those records. Copy the record IDs.
- Sign out, sign in as user B in a private window, and try to reach user A's records: paste the URLs, and repeat the copied requests with B's session (most browsers let you right-click a request and copy it as fetch or cURL).
- Try the write actions too: update, delete, and creating a record with A's ID as the owner.
- If you use Supabase, also look at the Table Editor, which flags tables that have Row Level Security turned off. Any such table holding user data is exposed to anyone with your public key.
Every one of these should fail with an error or an empty result. If any of them returns A's data, you have found an IDOR.
How do I fix it in Supabase?
Turn on Row Level Security on every table that holds user data and write a policy for each operation. For a table where each row belongs to one user:
alter table public.invoices enable row level security;
create policy "Owners can read their invoices"
on public.invoices for select
to authenticated
using ((select auth.uid()) = user_id);
create policy "Owners can update their invoices"
on public.invoices for update
to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);
Add matching insert (with with check) and delete policies only if users should be able to do those things. The with check clause matters: without it, a user could update a row they own and change its user_id to someone else's.
Two more things to look for:
- Shared data. If records belong to a team or organization, the policy must check membership (for example, that a row exists in a
membershipstable linking the user to that organization). Our guide to multi-tenant apps on Supabase covers that pattern. - Views and functions. Views and
security definerfunctions can bypass policies. Check that they apply the same ownership rule.
The Supabase docs on Row Level Security explain the policy syntax, and our RLS policy examples cover more patterns.
How do I fix it in a custom API?
If your app has its own backend or serverless functions, apply the same rule in code:
- Read the user's identity from the verified session or token, never from the body, query string or a header the client sets.
- Load the record and compare its owner (or organization) with that identity before returning or changing it.
- Return the same "not found" response whether the record doesn't exist or belongs to someone else, so IDs can't be probed.
- Put this check in one shared helper so every endpoint uses it, instead of repeating it by hand.
How do I confirm the fix worked?
Repeat the two-account test from above, for every table and endpoint, including write actions. Then run it once more after your next few AI-generated changes: a new feature can easily add a new table without policies. A short automated test that signs in as user B and tries to read user A's record catches regressions for good.
What if real users were affected?
Fix it first, then find out how far it went. Check your database and API logs for requests that read records belonging to other users, and look at the period the flaw existed. If personal data was exposed, you may have legal duties to report it or tell the people affected, depending on where you and they are. Read our plain-language overview of data protection for app founders and talk to a lawyer.
Quick answers
Is hiding the link in the interface enough? No. Anyone can type a URL or replay a request. The check has to happen on the server or in the database.
Do UUIDs prevent IDOR? They make guessing harder but don't stop someone who has seen an ID. Ownership checks are still required.
I turned on RLS and now everything is empty. What happened? RLS with no policies denies everything. Add policies for the operations each role needs.
Does this apply to file storage too? Yes. Storage buckets need their own policies; a public bucket serves any file to anyone with the path.
Need it checked properly?
The free health check shows what your app reveals from the outside. For a person to go table by table through your database rules and endpoints, our Supabase security review is built for exactly this, or you can describe what you found.


