Founder & Lead Engineer, RAITHub
When users can see another tenant's data, the cause is usually a query that trusts an ID from the request without also filtering by the caller's tenant, a cache keyed without the tenant, or a pooled connection carrying the previous request's tenant. Contain it first, trace the exact path, fix it in the code and the database, then prove it with a test that tries to cross.
This guide is for the day a customer emails a screenshot of someone else's invoice, or a support agent notices a dashboard showing the wrong company's numbers. It is written as engineering guidance for multi-tenant web apps on a relational database, with PostgreSQL examples. For the architecture behind tenant isolation, such as choosing between shared schema, schema per tenant and database per tenant, read how to build multi-tenant B2B SaaS; this post is about finding and fixing a leak that already exists.
What should you do in the first hour of a tenant data leak?
Stop the exposure, keep the evidence, and find out how far it went. Do not start by rewriting code.
- Reproduce it with two test tenants. Log in as tenant A and follow the same steps the customer did. Note the exact URL, request and response that show tenant B's data.
- Contain the path. Turn off the feature, return 404 from the affected endpoint, or purge and disable the cache that served it. A crude block now is better than an elegant fix tomorrow.
- Preserve evidence. Keep access logs, application logs and the database state. You will need them to work out who saw what.
- Scope the exposure. From the logs, list every request to the affected path since the change that introduced it, and which tenant's records each one returned.
- Tell the right people. Personal data exposed to the wrong customer can be a reportable breach. Under GDPR Article 33, a controller must notify the supervisory authority "without undue delay and, where feasible, not later than 72 hours after having become aware of it", unless the breach is unlikely to result in a risk to people. This is general information, not legal advice; confirm your obligations with your adviser.
What usually causes a cross-tenant data leak?
The OWASP API Security Top 10 ranks this bug first. API1:2023 Broken Object Level Authorization describes attackers "manipulating object IDs to access resources they shouldn't have permission to view or modify". It is often called IDOR, insecure direct object reference. In multi-tenant apps it has a few common shapes.
| Cause | What it looks like | Where to look |
|---|---|---|
| Lookup by ID only | Changing /invoices/1041 to /invoices/1042 shows another company's invoice | Every WHERE id = $1 without a tenant condition |
| Tenant taken from the request | A tenantId in the body, query string or a header the client controls | Handlers that read the tenant from anything except the verified session |
| List or search missing the filter | A search, export or report returns rows from every tenant | Aggregate queries, CSV exports, search indexes, analytics endpoints |
| Cache key without the tenant | Tenant B sees the page tenant A loaded a minute earlier | Cached functions, CDN rules for authenticated routes, shared in-memory caches |
| Pooled connection keeps a tenant | Intermittent: the wrong tenant's data appears under load | A session-level SET on a pooled connection instead of a transaction-scoped one |
| Background job runs as admin | Emails, digests or PDFs contain another tenant's records | Queue workers, cron jobs and report builders using a privileged database role |
| Guessable file URLs | A document link works for anyone who has it or can guess it | Public buckets, sequential file names, long-lived signed URLs |
| Database rules bypassed | Row-level security is on, yet rows still leak | The app connecting as the table owner or superuser, views, SECURITY DEFINER functions |
How do you trace which code path leaks?
Work from the request you reproduced, not from a hunch about which module is weak.
- Follow one request. From the route, through the handler and service code, to the SQL that runs. Log the final query with its parameters in a test environment, and check it names the tenant.
- Search for the pattern everywhere. The first leak is rarely the only one. Grep for queries that filter by
idalone, ORM calls likefindUnique({ where: { id } }), and any handler that readstenantIdfrom the request. - Check the response headers on authenticated pages and APIs. A response that varies by user needs
Cache-Control: privateorno-store, never a shared cache. - Check who the app is in the database. Run
SELECT current_userfrom the app's connection. If it owns the tables or is a superuser, row-level security is not protecting you. - Load-test the intermittent ones. If the leak only appears under load, suspect connection pooling or a shared cache, and reproduce it with two tenants making requests in parallel.
How do you fix a tenant leak in the application code?
Scope every query by the tenant from the verified session, and answer 404 for records outside it. OWASP's guidance is to check authorisation "in every function that uses an input from the client to access a record in the database".
// Before: any signed-in user can read any invoice by changing the ID
const { rows } = await db.query('SELECT * FROM invoices WHERE id = $1', [id])
// After: the tenant comes from the verified session, never from the request
const { rows } = await db.query(
'SELECT * FROM invoices WHERE id = $1 AND tenant_id = $2',
[id, session.tenantId],
)
if (rows.length === 0) return new Response('Not found', { status: 404 })
Three rules keep the fix from being a patch on one endpoint:
- One place resolves the tenant. A helper reads it from the session and passes it down. No handler reads it from input.
- 404, not 403. A 403 confirms the record exists. A 404 reveals nothing.
- Random IDs help, but are not the fix. OWASP recommends unpredictable identifiers such as GUIDs, which make guessing harder. A leaked or shared link still works if the query is not scoped.
For caches, put the tenant in every key, or do not cache per-tenant data in a shared layer at all.
How do you make the database refuse cross-tenant reads?
Add PostgreSQL row-level security (RLS), so a forgotten WHERE clause returns nothing instead of everything. RLS adds a condition to every query on a table, whatever the application sends:
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid);
The policy only protects you if the app connects as a role that does not own the tables and does not have BYPASSRLS, and if the tenant setting is scoped to each transaction, so a pooled connection cannot carry it into the next request. PostgreSQL's row security documentation is explicit that superusers and BYPASSRLS roles always bypass policies, and table owners do too unless the table uses FORCE ROW LEVEL SECURITY. The tenancy trade-offs are in the multi-tenant SaaS guide.
RLS is a second wall, not a replacement for scoped queries. Keep both: the application check gives clean 404s and readable code, and the database check catches the query someone writes next year.
How do you prove the leak is fixed?
With a test that plays tenant A and attacks tenant B's records on every endpoint that takes an ID, run on every build. OWASP's last recommendation is to "write tests to evaluate the vulnerability of the authorization mechanism" and not deploy changes that make them fail.
// Tenant A's session tries every verb on tenant B's records.
const cases = [
['GET', '/api/invoices/' + invoiceOfB],
['PATCH', '/api/invoices/' + invoiceOfB],
['DELETE', '/api/invoices/' + invoiceOfB],
['GET', '/api/invoices/' + invoiceOfB + '/pdf'],
['GET', '/api/customers/' + customerOfB],
]
for (const [method, path] of cases) {
it(method + ' ' + path + ' is refused for another tenant', async () => {
const res = await fetch(BASE_URL + path, { method, headers: { cookie: sessionOfA } })
expect(res.status).toBe(404)
})
}
it('list endpoints never return another tenant\'s rows', async () => {
const res = await fetch(BASE_URL + '/api/invoices', { headers: { cookie: sessionOfA } })
const body = await res.json()
expect(body.every((row: { tenantId: string }) => row.tenantId === tenantA)).toBe(true)
})
Add a database-level check too: a CI query that fails the build if any table with a tenant column has no policy. That catches the new table added next quarter without one.
This is how RAITHub runs isolation on Sundor Skin, a B2B wholesale platform it built: a 146-table PostgreSQL core with row-level security, where every buyer query runs under a policy scoped to that buyer, CI fails the build if a buyer-scoped table lacks a policy, and a 21-case IDOR suite tries to read other buyers' data among 530+ automated tests. Sundor Skin is not a SaaS, but isolating one business buyer from another is the same engineering problem. See the Sundor Skin case study.
What should you do after the fix?
- Repair and record. Write down which records were exposed, to whom and when. If data was changed across tenants, not just read, restore it.
- Close the class, not the instance. Apply the scoped-query helper and RLS to every tenant table, not only the one that leaked.
- Review caches and jobs. They are where the second leak usually hides.
- Keep the tests. The IDOR suite and the missing-policy check stay in CI permanently.
More on hardening in SaaS security best practices.
Why RAITHub for a tenant leak?
Because the fix has to be proven across the whole app, and RAITHub has built isolation that is tested on every build.
- Isolation tested in CI. Sundor Skin runs row-level security, a missing-policy CI check and a 21-case IDOR suite among 530+ tests.
- Several roles, one codebase. PropDesk serves landlords, tenants, contractors and admins with 1,024 automated tests.
- Root cause, not a patch. The fix report lists the leaking path, every similar path found, what was exposed and the tests that now guard it.
- Confidential by default. An NDA before you share access, and any code written is yours. RAITHub signs DPAs and SCCs and follows your controls. It is not SOC 2 or ISO 27001 certified.
When you don't need us
- It is one endpoint and you found it. Scope the query, add the test, run the grep for siblings, and you may be done.
- You need a formal penetration test or a certified auditor. Hire a specialist security firm; RAITHub fixes and tests code, and does not issue certifications.
- You need breach counsel. Notification decisions belong with a lawyer, not an engineering partner.
If you have a leak now, send RAITHub the reproduction steps and the affected endpoint. For a wider review of a codebase you inherited, see the code rescue service or the fix one issue page.
Last reviewed: 29 September 2026. PostgreSQL behaviour checked against the PostgreSQL 18 documentation.
Frequently asked questions
Why can users see another tenant's data?
Usually because a query looks a record up by ID without also filtering by the caller's tenant. Other common causes are a tenant ID read from the request, a cache key without the tenant, a pooled database connection carrying the previous request's tenant, and background jobs running with admin rights.
What is an IDOR vulnerability?
Insecure direct object reference: a user changes an ID in a request and gets a record that is not theirs. OWASP lists it as API1:2023 Broken Object Level Authorization, the first item in its API Security Top 10.
Should I return 403 or 404 for another tenant's record?
404. A 403 confirms that the record exists, which leaks information. A 404 treats records outside the caller's tenant as if they did not exist.
Does row-level security stop tenant leaks on its own?
Only if it is set up correctly: forced on the table, with the app connecting as a role that neither owns the tables nor has BYPASSRLS, and the tenant set per transaction. Keep scoped queries in the application as well.
Do I have to report a cross-tenant data leak?
Possibly. Under GDPR Article 33, a personal data breach must be reported to the supervisory authority within 72 hours where feasible, unless it is unlikely to result in a risk. This is general information; confirm with your adviser.
How do I test tenant isolation?
Log in as one tenant and try every endpoint that takes an ID with another tenant's records, expecting 404, and check that list endpoints return only your own rows. Add a CI check that fails if a tenant table has no row-level security policy.
Related posts
Ready to discuss your project?
Book a free 15-minute technical audit with our engineering team.