IDOR vulnerability: when one user can see another user's data

An IDOR vulnerability (insecure direct object reference) means your app returns the record named by an id in the request without checking that it belongs to the person asking. Change the id in GET /api/orders/123 and another customer's order comes back. The fix is an ownership check on the server or in the database, on every read and write. Test it with two accounts you own.

The team behind Keybrum's attacksUpdated 14 min read

In short

  • Being signed in is not permission. Every request that names a record needs a check, on the server or in the database, that the record belongs to the caller.
  • Firebase rules that only test request.auth != null and Supabase policies that only check the authenticated role let any account read everything, and wherever sign-up is open, anyone can make an account.
  • A .eq('user_id', user.id) in your frontend is a filter, not a rule: whoever calls your API directly leaves it out.
  • Test with two accounts you own: create a record as A, request it as B, and expect a 403, a 404 or an empty result, never A's data.
  • Random UUIDs make ids harder to guess, but ids leak through links and API responses. They do not replace the ownership check.
On this page

What an IDOR vulnerability is

An IDOR vulnerability (insecure direct object reference) is an access control bug: the app takes an id from the request, fetches that record, and never checks that it belongs to the person asking. The user is signed in, so the request looks normal. The record just happens to be someone else's.

Take an online shop. Alice opens her order and her browser calls GET /api/orders/123. Bob, signed in to his own account, sends the same request. On a correct app he gets a 404. On an app with an IDOR he gets Alice's name, address and basket, and he can go on to 124, 125 and the rest.

http
# Alice, signed in, opens her own order
GET /api/orders/123
Cookie: session=ALICE_SESSION
200 OK   {"id": 123, "user_id": "alice", "address": "..."}

# Bob, signed in to his own account, asks for the same id
GET /api/orders/123
Cookie: session=BOB_SESSION
200 OK   {"id": 123, "user_id": "alice", ...}    vulnerable
404 Not Found                                     fixed
The login worked both times. The missing question is: does order 123 belong to Bob?

IDOR is one form of broken access control, the category at the top of the OWASP Top 10 for 2025. OWASP describes it as "viewing or editing someone else's account by providing its unique identifier", and reports that 100% of the applications tested in its data had some form of broken access control. In APIs the same bug is called BOLA, broken object level authorization, ranked first in the OWASP API Security Top 10 (2023). MITRE files it as CWE-639.

NameWhere you meet itWhat it means
IDOR, insecure direct object referenceBug bounty reports, the OWASP Top 10Changing an id in a URL, a body or a header returns another user's object
BOLA, broken object level authorizationOWASP API Security Top 10, API1:2023The same bug in an API: the endpoint never checks that the caller may act on that object
Broken access controlOWASP Top 10, A01:2025The whole family: IDOR, force browsing by editing the URL, APIs with no checks on POST, PUT and DELETE
CWE-639CVE records and scannersAuthorization bypass through a user-controlled key

Why a valid login does not stop it

Authentication answers one question: who is this? Authorization answers another: may this person touch this record? An IDOR is what you get when the code does the first and skips the second. It survives manual testing because, with one account, the frontend only ever asks for the right data. It shows up only when a second account asks for the first one's records.

Three patterns come up again and again in the generated apps we attack.

1. The filter lives in the browser

js
// Runs in the visitor's browser
const { data } = await supabase
  .from("orders")
  .select("*")
  .eq("user_id", user.id);

This line decides what your screen asks for, not what the database will hand over. The anon key and the table name are both in your JavaScript, so anyone can send the same query without the .eq() and receive every order in the table, unless row level security (RLS) stops them. Supabase's documentation describes a policy as a WHERE clause added to every query: that clause has to live in the database, not in your component.

2. The rule checks sign-in, not ownership

rules
// Firestore rules: any signed-in account reads and writes every order
match /orders/{orderId} {
  allow read, write: if request.auth != null;
}

request.auth != null only means that someone is signed in. Firebase's guide to insecure rules lists this exact pattern as giving any logged-in user access to the data. And being logged in is a low bar: when email sign-in is enabled, the Firebase Auth REST API creates an account with nothing more than the public API key from your config, whether or not your app shows a sign-up form, unless the project was upgraded to Identity Platform and self sign-up was turned off there. The Supabase version of the same mistake is a policy to authenticated using (true), or one that only tests auth.role() = 'authenticated'.

3. The API route checks the session, then trusts the id

ts
// app/api/orders/[id]/route.ts
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
  const session = await auth();
  if (!session) return new Response("Unauthorized", { status: 401 });
  const { id } = await params;
  const order = await db.order.findUnique({ where: { id } }); // anyone's order
  return Response.json(order);
}

The session check stops people who never signed up, but any customer still gets every order by id. Next.js Server Actions have the same trap: the Next.js documentation warns that each one can be called with a direct POST request, so each one needs its own authorization check.

What another account can do with an IDOR

The id can sit in the path, the query string, the body or a header. What user B gets depends on where the check is missing:

The requestWhat user B can doTypical damage
GET /api/orders/123Read one of A's recordsNames, addresses, invoices, private messages
GET /rest/v1/orders?select=*Read every row of a table, no id neededThe whole customer base in one request
PATCH /api/profile/123Change A's recordA changed email address, payout details or content
DELETE /api/projects/123Delete A's recordData gone for a customer who did nothing wrong
POST /api/orders with an owner id in the bodyCreate records in A's nameFake orders and poisoned data
/uploads/invoice-1042.pdfDownload A's files by guessing namesID scans, contracts, receipts

This happens to large companies too. In 2019, KrebsOnSecurity reported that First American Financial exposed about 885 million mortgage-related files dating back to 2003, including bank account numbers and Social Security numbers. Each document sat behind a link with a number in it, changing the number returned other people's records, and no login was required. In 2025, researchers Ian Carroll and Sam Curry got into a test restaurant account on McHire, the hiring platform Paradox.ai runs for McDonald's, which still accepted a default password. From there, an internal API returned other applicants' names, email addresses, phone numbers, home addresses and application answers when they lowered a numeric id. Their write-up puts the reachable records at more than 64 million, and Paradox.ai confirmed the fix the day after the report.

How to test your own app with two accounts

The first pass needs no security tool, only two accounts and your browser's developer tools. Do it on your own app, with test accounts you created. Never use real customer accounts, and never test an app you do not own.

  1. 1

    Create two test accounts

    Call them A and B. Use two browsers, or a normal window and a private one, so the sessions never mix. Two email addresses you control are enough.

  2. 2

    As A, create one of everything, with a marker

    Create one record of each type your app has: an order, a project, a note, an upload. Put an obvious marker in a text field, like canary-A. Write down each record's id: it shows in the address bar, or in the developer tools under Network when you click the request.

  3. 3

    As B, ask for A's records

    Paste A's URLs into B's browser first. Then, in B's Network tab, right-click a request B's own page made, choose Copy as cURL, replace B's id with A's and run it in a terminal. The answer must be a 403, a 404 or an empty result (Supabase answers 200 [] for rows that RLS hides). A 200 that contains canary-A is an IDOR.

  4. 4

    Try writes, lists and exports, not only reads

    Repeat with the update, delete, list, search and export requests. After each write attempt as B, reload the record as A: it must be unchanged. A success code alone proves nothing either way: Supabase, for example, answers an update that RLS filtered out with success and zero rows changed.

  5. 5

    Repeat signed out

    Send the same requests with no cookie and no Authorization header. Anything that still answers is open to the whole internet, which is worse than an IDOR. The Supabase anon key guide covers that case.

In Supabase: ask the database as user B

The SQL editor can play user B inside a transaction that changes nothing. Copy both user ids from Authentication > Users in the Supabase dashboard, and run:

sql
begin;
set local role authenticated;
set local request.jwt.claim.sub = 'USER_B_ID';

-- rows user B can see that are not user B's
select count(*) from public.orders
where user_id is distinct from 'USER_B_ID'::uuid;

rollback;
Run it after A has created rows, or a 0 proves nothing. Expect 0. Any other number is how many rows of other users B can read. Repeat for every table that holds user data, with its own owner column and type.

The Supabase RLS checker lists the tables with RLS off and the policies open to everyone: test those first.

In Firebase: the Rules Playground

In the Firebase console, open Cloud Firestore (or Realtime Database, or Storage), then Rules, then Rules Playground. Simulate a read at the path of one of A's documents, as an authenticated user with B's uid: the result must be denied. Run the same read with A's uid: it must be allowed, which shows the test reaches the rule you meant to test. For checks you can rerun after every change, Firebase recommends unit tests against the local emulator.

How to fix an IDOR: check ownership where the data lives

Every fix follows the rule OWASP gives: access control should enforce record ownership. Take the user's identity from the session or the token, never from the request. Scope every read and every write to that user. Deny anything you did not allow on purpose.

Supabase: owner policies in row level security

sql
-- Bad: every signed-in account reads every order
-- create policy "signed in users" on public.orders
--   for select to authenticated using (true);
drop policy if exists "signed in users" on public.orders;

-- Good: each user reaches only their own rows
alter table public.orders enable row level security;

create policy "own orders: read" on public.orders
  for select to authenticated using ((select auth.uid()) = user_id);

create policy "own orders: create" on public.orders
  for insert to authenticated with check ((select auth.uid()) = user_id);

create policy "own orders: update" on public.orders
  for update to authenticated
  using ((select auth.uid()) = user_id)
  with check ((select auth.uid()) = user_id);

create policy "own orders: delete" on public.orders
  for delete to authenticated using ((select auth.uid()) = user_id);

create index if not exists orders_user_id_idx on public.orders (user_id);

The with check on insert stops B from creating rows in A's name. On update, it stops B from handing his row to someone else. Wrapping auth.uid() in select and indexing user_id are Supabase's own advice for keeping policies fast. Keep your .eq('user_id', user.id) in the frontend if you like: it is now a filter on top of a rule. For teams and shared records, the Supabase RLS guide has membership policies.

Firebase: compare the caller with the owner field

rules
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /orders/{orderId} {
      allow read, delete: if request.auth != null
        && resource.data.ownerId == request.auth.uid;
      allow create: if request.auth != null
        && request.resource.data.ownerId == request.auth.uid;
      allow update: if request.auth != null
        && resource.data.ownerId == request.auth.uid
        && request.resource.data.ownerId == resource.data.ownerId;
    }
  }
}

resource.data is the document as stored, request.resource.data is the document being written. Checking both on update stops B from taking over a document or giving his own away. One difference from Supabase: Firestore rules are not filters. A query must ask only for documents the rules allow, here with where("ownerId", "==", user.uid), or Firestore rejects the whole query. The Firebase security rules guide covers Storage and Realtime Database too.

Your own API: take the user from the session, scope the query

ts
// app/api/orders/[id]/route.ts
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
  const session = await auth();
  if (!session?.user) return new Response("Unauthorized", { status: 401 });
  const { id } = await params;

  // The order must exist AND belong to the caller
  const order = await db.order.findFirst({ where: { id, userId: session.user.id } });
  if (!order) return new Response("Not found", { status: 404 });

  return Response.json(order);
}

Put the owner in the query rather than checking after the fetch: if someone later forgets an if, the query still returns nothing. Answer 404 for another user's record, so B cannot even confirm the id exists. Do the same in the update, delete and list handlers, and in every Server Action, as the Next.js data security guide shows. On create, set userId from the session and ignore any owner field sent in the body.

The traps people miss after the first fix

  • Random ids are not a fix. UUIDs are hard to guess, but they leak through shared links, emails, logs and other API responses. They are an extra layer, never the access check.
  • An old policy still lets everyone in. In Postgres, permissive policies for the same command are combined with OR. A forgotten using (true) policy next to your new owner policy keeps the table open. List what is left with select policyname, permissive, cmd, roles, qual, with_check from pg_policies where tablename = 'orders';.
  • Child tables stay open. Locking orders while order_items or invoices keep a loose policy leaks the same data one join away. Each table needs its own policy, often an exists check on the parent row. Views, unless created with (security_invoker = true), and security definer functions run with their owner's rights and skip RLS.
  • Files have owners too. A public bucket serves any file to anyone who has its path. Keep private files in a private bucket with per-user folder policies, as the Supabase storage security guide explains.
  • The owner comes from the body. A create or update that accepts user_id, owner_id or role from the request lets B write into A's account, or promote himself. This is called mass assignment.
  • Anonymous sign-ins widen `authenticated`. If you turn on anonymous sign-ins in Supabase, those users take the authenticated role, so policies that only check the role admit anyone who opens your app. Use the is_anonymous claim where it matters.

How Keybrum tests for IDOR on a live app

Keybrum runs the two-account test above against your live app, once you have proved you own it, and keeps every request as proof.

  • Throwaway accounts. When sign-up is open, Keybrum signs up the way a stranger would, with addresses on its own domain. It never uses a real customer or admin account.
  • Every table and collection, as a signed-in stranger. On Supabase, the test account reads each table the public key reaches and counts the rows that are not its own. On Firebase, it reads the Firestore collections named in your code. Write probes are built not to keep data: Supabase probes ask the database to roll back, and Firebase probes carry a precondition that can never hold.
  • Marked canary records on your own API. If you turn on active canaries (they are off by default), account A creates one record through your documented API, carrying a unique scan marker. This needs a documented resource with create, read and delete routes. An anonymous request for it must be refused. Then account B asks for A's id. It counts as a finding only when B receives A's exact marker.
  • Reversible writes. For updates, B changes one harmless field such as a title, A reads the change back, and the original value is restored and checked. The record is then deleted, and a follow-up read must return 404 or 410.
  • Stated limits. Routes about payments, messages, invites or webhooks are never used for canaries. Anything Keybrum could not test is listed as not tested, never as passed.

Your coding agent reads each finding and its fix through Keybrum's MCP server. Keybrum attacks again after the fix and, on a plan, on every deploy, since a new table or route can bring the bug back. IDOR checks are one part of a web application penetration test, and one of the vibe coding security risks worth testing before launch.

Questions

What is the difference between IDOR and broken access control?

Broken access control is the category: any case where a user acts outside their permissions. IDOR is one kind of it, where changing an object's id in a request reaches another user's object. OWASP lists insecure direct object references as an example inside A01:2025 Broken Access Control.

Is IDOR the same as BOLA?

In practice, yes. BOLA, broken object level authorization, is the name OWASP uses for the same bug in APIs, ranked API1 in its 2023 API Security Top 10. Any endpoint that receives an object id needs an object level check.

Do UUIDs prevent IDOR vulnerabilities?

No. Random ids are harder to guess, but they leak in URLs, shared links and API responses. MDN and the OWASP cheat sheet both call complex ids defense in depth, never a replacement for the ownership check.

How do I stop one user from seeing another user's data in Supabase?

Enable row level security on every table with user data, add owner policies that compare (select auth.uid()) with the owner column, and drop any policy whose condition is true or that only checks the authenticated role. Then test as a second user. The Supabase RLS guide has policies for teams and shared records.

Is request.auth != null enough in Firebase rules?

Only for data every signed-in user should see. For private data, compare request.auth.uid with an owner field in the document, or with a {userId} segment in the path. Firebase's own guide to insecure rules flags the sign-in-only pattern.

How do I test for IDOR in an API?

Use two accounts you own. As B, replay every request that carries one of A's ids, in the path, the query, the body or a header, with every method. Expect a 403, a 404 or an empty result, and check as A that no write went through.

Can an automated scanner find an IDOR?

Only one that knows whose data came back. A scanner with no session never sees the bug, and one with a single session usually cannot tell its own records from someone else's. The reliable proof is two accounts and a record whose owner is known, which is how Keybrum's canary test works.

Sources

  1. 1.A01:2025 Broken Access Control, OWASP Top 10:2025
  2. 2.API1:2023 Broken Object Level Authorization, OWASP API Security Top 10
  3. 3.Insecure Direct Object Reference Prevention Cheat Sheet, OWASP Cheat Sheet Series
  4. 4.CWE-639: Authorization Bypass Through User-Controlled Key, MITRE
  5. 5.Insecure Direct Object Reference (IDOR), MDN Web Docs
  6. 6.Row Level Security, Supabase Docs
  7. 7.Anonymous Sign-Ins, Supabase Docs
  8. 8.Fix insecure rules, Firebase Docs
  9. 9.Securely query data (rules are not filters), Firebase Docs
  10. 10.Quickly validate Firebase Security Rules (Rules Playground), Firebase Docs
  11. 11.Firebase Auth REST API, Firebase Docs
  12. 12.Identity Platform users in projects (user self-service), Google Cloud
  13. 13.How to think about data security in Next.js, Next.js Docs
  14. 14.CREATE POLICY, PostgreSQL documentation
  15. 15.Would you like an IDOR with that? Leaking 64 million McDonald's job applications, Ian Carroll
  16. 16.First American Financial Corp. Leaked Hundreds of Millions of Title Insurance Records, KrebsOnSecurity

Your app is public. So are its mistakes.

Check what any attacker can already see in it, free, in ten seconds. The full attack, with proof and fixes: €49 once, or €29 a month on a plan.

or continue with Google or GitHub