It’s the failure that turns a small launch into an incident: customer A opens the app and sees customer B’s data. In multi-tenant apps — anything with separate users, teams or organizations — it’s one of the most common and most damaging bugs AI builders generate, and it comes from a single bad habit: trusting an id the browser sent.
What it looks like
Your app loads an invoice at /api/invoices/1042. Behind it: select * from invoices where id = 1042. It works — you see your invoice. Now someone changes the URL to 1043, or sends a different org_id in the request body, and the query happily returns a record that isn’t theirs. There was never a check that this user is allowed that row.
Security people call this IDOR — insecure direct object reference. In plain terms: the app asked the client which data to fetch, and believed the answer.
Why AI builders generate it
The happy path is the trap. While building, you only ever act as yourself, on your own data, so where id = ? looks correct — it returns the right thing every time you test. The AI optimizes for the demo working, and the demo works. The missing clause — and this row belongs to the current user or tenant — never shows up until a second, real tenant exists. Generated code also tends to take org_id straight from the request because it’s right there and it makes the query run.
The fix: identity comes from the session, never the request
The rule is one line: the server decides which tenant a request belongs to, from the authenticated session — it never trusts a tenant id the client sends.
- Derive
user_id/org_idfrom the verified session, server-side. - Scope every query by it:
where id = ? and org_id = :sessionOrgId. - On Supabase or Postgres, enforce it in row-level security so the database itself refuses cross-tenant rows — the backstop that holds even when an endpoint forgets.
- Do it for writes too. Locking reads but leaving
updateanddeleteopen lets one tenant modify or destroy another’s data.
How to test it
Create two accounts in two different orgs. As user A, note the id of one of your records. Log in as user B and try to fetch, update and delete A’s record by its id — by editing the URL, changing the request body, or calling the API directly. If anything succeeds, the boundary isn’t really there.
A tenant-isolation leak is invisible in every solo test and obvious the moment two real customers are in the system. Confirming the boundary is enforced server-side — on reads and writes, ideally at the database — is core to a seniorgrade audit. If you’re on Supabase, the Supabase audit breakdown covers exactly where this hides, and the free self-check asks about it too.