How Do I Make Sure One Customer Can't See Another Customer's Data?
6 min read
If your app serves multiple users or companies, the way you stop one account seeing another's data is simple to state: every read and write must be scoped to the logged-in user on the server, using their session, not an ID that came from the request. If the account or company ID arrives in the URL, the body, or a header, and your code trusts it, one customer can load another's data by changing a number. This is the single most common launch-killer in agent-built apps, and it never shows up in a demo.
Why this happens
An AI agent builds exactly what you ask. You said "load the invoice for this
ID," so it wrote GET /invoices/:id and fetched that row. It works perfectly in
your testing, because you only ever have one account open. What you did not ask
for, and so did not get, was the check that says "and only if this invoice
belongs to the person asking." The ownership rule is invisible until a second
customer exists, and by then it is live.
This is called broken access control, or IDOR (insecure direct object reference). The ID is real, the row is real, the only thing missing is the question "is this yours?"
How to check
You need two accounts. Sign up as customer A, create something (an invoice, a project, a document), and note the ID or the full URL. Now sign in as customer B in a separate browser or private window.
- Paste A's URL into B's session. If B can see A's record, you have a leak.
- Watch the network tab. Find a request that carries an ID, for example
/api/orders/1042or?company_id=7. Change the number to one you know belongs to A and resend it (right-click the request, "copy as cURL", edit, run). If A's data comes back, that endpoint is unprotected. - Check list endpoints too. A
/api/ordersthat returns every order, not just B's, is the same bug in bulk.
If either account can reach the other's data, you are affected. Assume every endpoint is until you have tested it.
The fix
- Derive the identity on the server, never from the request. Take the user
from the verified session or token. Never read
user_idorcompany_idfrom the URL, body, or a header to decide whose data to return. - Filter every query by that identity.
WHERE company_id = :session_companyon every select, update, and delete. A fetch by primary key still needs the ownership clause:WHERE id = :id AND company_id = :session_company. - Return 404, not the row. If the record is not theirs, respond as if it does not exist. Do not return it, and do not confirm it exists.
- Push the rule down to the database. On Supabase, enable
Row Level Security
and write a policy per table (
company_id = auth.jwt() ->> 'company_id') so the isolation holds even if an endpoint forgets. On Firebase, write Security Rules that match the document's owner to the signed-in user. - Cover writes as well as reads. Changing another customer's data is worse than viewing it. The same ownership clause guards update and delete.
The trap to avoid
Hiding the ID is not a fix. Swapping a sequential 1, 2, 3 for a random UUID
makes leaks harder to stumble into, but a determined person still gets the ID
(shared links, logs, referrers), and the endpoint still hands over the row. The
lock is the ownership check on the server, not the obscurity of the number.
Equally, do not rely on the frontend hiding a button. Anyone can call the API
directly.
Where this fits
Tenant isolation is the gap most likely to end a launch, and it is exactly the kind of thing that is invisible until your second real customer signs up. The free Readiness Report tests it against your actual project: your agent walks the endpoints with two identities and tells you which ones leak, before a customer finds out for you. If you would rather have the ownership checks written in and verified, that is the Finishing Pass. For the request-tampering side of the same problem, see someone could change the user ID in my API request and why do users see someone else's account.