Home › Blog › Multi-tenant isolation

Multi-tenant isolation: how IDOR leaks data between your SaaS customers

October 12, 2026 · ~9 min read

Almost every SaaS is multi-tenant: all your customers live in the same database, separated by a tenant_id (or user_id, org_id). Isolation between them depends on one check on every access. Forgetting a single one is the #1 flaw in the OWASP API Top 10 — IDOR / BOLA — and it's the one most likely to turn a new SaaS into bad news. This is the guide to the flaw: why it happens, the 7 patterns, and where the check has to live.

Why it's so dangerous: it isn't a customer seeing their own data — it's a customer seeing everyone else's. One endpoint missing the ownership check, and any logged-in customer iterates IDs and downloads the whole base. No exploit needed: just change a number in the URL.

IDOR / BOLA in one sentence

IDOR (Insecure Direct Object Reference) and BOLA (Broken Object Level Authorization) are the same problem: the app uses an identifier supplied by the client to fetch an object, but doesn't confirm that object belongs to the requester. Authentication (who you are) is correct; object-level authorization (can you see this one?) is missing.

1. Authorizing by "logged in", not "is owner"

The classic pattern. It passed auth, so it reads any object:

app.get('/invoices/:id', auth, async (req, res) => {
  const inv = await db.invoices.findById(req.params.id);
  res.json(inv); // ❌ never checks inv.tenantId
});

// ✅ the lookup is already scoped to the session's tenant
const inv = await db.invoices.findOne({
  id: req.params.id, tenantId: req.session.tenantId
});
if (!inv) return res.sendStatus(404);

2. Trusting a tenant_id the client sends

If the tenant comes from a query param, header or request body, the client just picks another and crosses the boundary. The tenant must come from the server-side session/token, never from the request.

// ❌ client picks the tenant
const tid = req.query.tenantId || req.body.tenantId;
// ✅ tenant comes from the verified token
const tid = req.session.tenantId;

3. Thinking a random ID (UUID) is security

Swapping sequential IDs for UUIDs makes them harder to guess, but that's not authorization. IDs leak in URLs, logs, emails, referrers and API responses — the attacker rarely needs to guess. UUID is defense in depth; the ownership check is still mandatory.

4. Forgetting the scope on list endpoints

Worse than leaking one object is leaking the collection. A list route without the tenant filter returns the whole base at once.

// ❌ lists everything from everyone
const rows = await db.orders.find({});
// ✅ scope is mandatory
const rows = await db.orders.find({ tenantId: req.session.tenantId });

5. Nested resources without re-checking the chain

On /projects/:pid/tasks/:tid it's common to check the project and trust the task — then someone passes a tid from another project/tenant. Validate the whole chain: the task belongs to the project, and the project belongs to the tenant.

const task = await db.tasks.findOne({
  id: tid, projectId: pid
});
if (!task || project.tenantId !== req.session.tenantId) return res.sendStatus(404);

6. Cache, storage and files without the tenant in the key

Isolation isn't just the database. A cache key user:settings without the tenant serves the wrong customer's data; a file path /uploads/invoice.pdf without the tenant lets one download another's. Put the tenant in the key of everything shared.

cache.get('settings')            // ❌ shared across tenants
cache.get(`t:${tid}:settings`)   // ✅ isolated per tenant

7. Jobs, exports and webhooks without scope

Scheduled reports, CSV exports, reprocessing and webhook handlers run outside the request context — and it's easy for them to forget the tenant, mixing customers' data into one file or email. Every background process has to carry and respect the tenant explicitly.

Where the check has to live

Defense in depth: enforce isolation at the data layer — Supabase RLS or Firebase rules, which hold even if the code forgets — and at the application layer (the per-tenant scope on every query). One doesn't replace the other: RLS is the safety net; the app check is the door.

How to test today: create two tenants (A and B). Logged in as A, capture the IDs of your objects. Now, logged in as B, try to read/modify each one — via the URL, the API, everything. Any response that isn't 403/404 is a leak. Worth automating this in an integration test that runs in CI. Why pattern tools miss it: see SAST and what Semgrep misses.

Sure one customer can't see another?

SAAS SHIELD reviews your repository looking for exactly this — object-level authorization, tenant scope and RLS/rules — and returns every flaw with its location, evidence and fix.

Request an audit →