SaaS testing
Multi-tenant isolation testing: how to check that tenants cannot see each other
Multi-tenant security testing for SaaS: a two-tenant matrix of 12 areas (IDs, search, exports, caches, files), a sample leaking request and row-level security fixes.
Short answer
Multi-tenant isolation testing checks that one customer (tenant) of a SaaS product cannot read or change another tenant's data. Testers create two tenants with test data, run the same actions from each, and look for any cross-tenant read or write. They cover IDs, search, exports, caches, files, background jobs and roles. Fixes enforce the tenant at the data layer.
Multi-tenant isolation testing checks that one customer of a SaaS product (a tenant) cannot read or change another tenant's data. Testers create two tenants with known test data, run every feature from each, and look for any cross-tenant effect.
A tenant leak is a serious SaaS finding, because it exposes one customer's data to another. It is a form of broken access control, A01:2025 in the OWASP Top 10, and a tenant-level version of IDOR. This page gives the matrix we use, a sample request and fixes.
How do testers set up a two-tenant test?#
Set up two tenants that are as alike as possible. Use the same plan, the same roles and the same feature flags, so that any difference in results points to a flaw, not to a setting.
- Create Tenant A and Tenant B. Mark both as test accounts.
- In each tenant, create a member and an admin.
- In every feature, create a known record. Give each record a distinct, readable marker, such as
A-note-001andB-note-001. - Record every ID the app returns: users, projects, files, invoices, API keys, webhooks.
- Get the system owner's written authorization before testing.
The markers make a leak easy to prove: if tenant A's response contains B-note-001, the leak needs no interpretation.
What is the two-tenant test matrix?#
Run each row from tenant A's member, tenant A's admin and an anonymous caller. Target tenant B's data each time.
| Area | What testers try | Proof of a leak |
|---|---|---|
| Object IDs | A requests B's project, invoice or user by ID | B's marker in the response |
| Lists and pagination | A lists objects with a changed tenant_id, org or account parameter |
B's rows in the list |
| Search | A searches for B-note |
B's records in results |
| Exports and reports | A starts an export, then fetches B's export job ID | B's file content |
| Files and images | A opens B's file URL | B's file bytes |
| Caches | A and B request the same URL in turn | One tenant sees the other's cached body |
| Background jobs | A triggers a job with B's resource ID | B's data in the job output |
| Webhooks and integrations | A points an integration at B's resource | B's data delivered |
| Invitations | A invites an email into B's org by changing the org ID | New member appears in B |
| Roles | A member calls an admin route of A, then of B | Admin action succeeds |
| Billing and entitlements | A reads or changes B's plan, seats or limits | B's plan shows a change |
| Admin and support tools | A reaches staff-only routes | Any response with other tenants' data |
How does a tenant leak look in a request?#
A tenant leak often appears when a request carries a tenant identifier that the server trusts.
Illustrative cross-tenant read
This is a demonstration. No real customer system was tested. The app, hosts and data are fictional.
Tenant A's member calls the project list and swaps the tenant header:
GET /api/v1/projects HTTP/1.1
Host: api.harbor-notes.example.com
Authorization: Bearer <token-of-tenant-A-member>
X-Tenant-Id: tenant-b
A vulnerable server returns tenant B's rows:
HTTP/1.1 200 OK
Content-Type: application/json
{"items": [{"id": "p-300", "name": "B-project-001", "tenant": "tenant-b"}]}
The marker B-project-001 proves the leak. A safe server ignores the header and returns tenant A's projects, or returns 403.
The tester then runs the controls: tenant A's own ID, a nonexistent tenant ID, B's header with no token, and B's header with a token for a different, valid tenant. Controls that held go in the report.
Why do tenant leaks hide in caches, search and files?#
Shared layers outside the main query path hide tenant leaks. App code may filter by tenant on the main table and forget the same filter elsewhere.
- Caches. A cache key made from the URL alone (without tenant or user) can serve one tenant's page to another. Test by requesting the same path from both tenants and comparing bodies.
- Search indexes. The index may hold all tenants' documents, with the filter applied only in some query types. Test with search, autocomplete and "similar items" features.
- File storage. Files may live in one bucket with guessable or leaked keys. Test whether a file URL works without a token, or with another tenant's token.
- Background jobs and exports. A job may run with a service identity that has access to all tenants. Test by passing another tenant's resource ID.
- Logs and audit views. An activity feed may include other tenants' events.
How do testers prove cross-tenant writes?#
Writes need the same care as reads. Change only a test-owned record in tenant B, then read it back through B's session. A 200 OK from the write request is not proof, while the read-back from B shows whether the change took effect.
For destructive actions such as delete or role change, use a throwaway record created for the test. State this in the scope. The report should say which test records the testers changed.
How do teams fix tenant isolation flaws?#
Enforce the tenant at the data layer, not in each handler. Take the tenant from the verified session or token, never from a header, body field or URL that the client controls.
For row-level security in PostgreSQL, a policy filters rows by a setting that the app sets per request from the session (see the PostgreSQL row security docs). A sketch:
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = current_setting('app.tenant_id')::uuid);
The app sets app.tenant_id from the authenticated session at the start of each request. The app's database role must not own the table or have the BYPASSRLS attribute, because table owners and such roles skip the policy unless forced.
For the other layers, include the tenant ID in every cache key, search filter, file path and job payload, and give background workers a per-tenant scope. Add a build test that runs two tenants through each new route.
What should you do next?#
Run the matrix on every release that adds a new object type, export or integration. Our SaaS penetration testing service uses this two-tenant method, with written scope and evidence for each finding. The IDOR basics are in the IDOR testing guide. For the API risk list, see the OWASP API Security Top 10 guide. Billing and entitlement flaws are a business logic topic: see business logic vulnerabilities.
What does a tenant isolation finding look like?#
A finding names the shared layer that failed and shows the marker that crossed the boundary. This illustrative write-up shows the shape.
Illustrative finding write-up
This is a demonstration. No real customer system was tested. The app and tenants are fictional.
| Field | Detail |
|---|---|
| Title | Search returns documents from other tenants |
| Label | Observed |
| Route | GET /api/v1/search?q=B-note |
| Accounts | member of tenant A, member of tenant B, both created for this test |
| Result | A's search returns tenant B's note B-note-001. The main list route returns only tenant A data. |
| Controls that held | Direct fetch of B's note by ID returns 404. Export jobs are scoped to the caller's tenant. |
| Cause (inferred) | The search index holds all tenants and the filter is missing in this query type. This is an inference, so we label it separately from the observed result. |
| Fix | Add the tenant filter inside the search layer, from the session. Add a test with two tenants. |
What evidence should you keep?#
Keep evidence that lets someone else replay the test.
- Request and response pairs, with timestamps, for tenant A and tenant B.
- The marker that crossed the boundary.
- The read-back from the owning tenant for any write.
- The control results.
- A note of which test records were changed or deleted.
Our methodology page describes the evidence labels we use. Put inferred causes on their own line, as the sample does.
Questions
How many tenants do testers need?
At least two, with the same plan and roles. A third tenant helps for sharing and invitation tests. Each tenant needs a member and an admin account, and known test data in every feature.
Is a separate database per tenant safer than shared tables?
It narrows the blast radius of a missing filter, but it does not remove the risk. Caches, queues, file storage, search indexes and admin tools can still be shared. Test those layers whatever the database design.
Can we run isolation tests in production?
Yes, if the tests use only test-owned tenants and the scope says so in writing. A staging copy with synthetic data is often the better choice. Never test with real customer tenants.
What is row-level security?
Row-level security (RLS) is a database feature that filters rows by a policy for each query. It enforces the tenant check in the database, so one forgotten WHERE clause in app code cannot leak data.
Sources
- OWASP API Security Top 10 2023: API1 Broken Object Level Authorization (checked 9 November 2026)
- OWASP Top 10:2025 Introduction (checked 9 November 2026)
- PostgreSQL documentation: Row Security Policies (checked 9 November 2026)
- OWASP Multi-Tenant Application Security Cheat Sheet (checked 9 November 2026)