Zurück zum Blog
14. August 2026

When org_id Is a Parameter, Not a Boundary: Multi-Tenant Data Isolation Failure

Viktor Bulanek
Founder & CTO, Penetrify
MSc IT Security · 20+ years in security · 4x Ex-CTO

This is the finding we report most often against B2B SaaS, and the one that scanners structurally cannot produce. There is no signature for it, because the rule being broken exists only in your product: this row belongs to that customer.

The mechanics are almost always the same. An organisation identifier travels in the request — a path segment, a query parameter, a JSON field, a header — and the handler uses it to scope the query without checking that the authenticated user belongs to that organisation. The application works perfectly for every honest client, and returns any tenant's data to a dishonest one.

The Two Lines That Decide Whether You Have Isolation

Compare these:

// vulnerable: the tenant comes from the caller
const { orgId } = req.params;
const invoices = await db.invoice.findMany({ where: { orgId } });

// safe: the tenant comes from the session
const orgId = session.user.orgId;
const invoices = await db.invoice.findMany({ where: { orgId } });

Both are one line. Both look like scoping. The first is an authorisation flaw and the second is not, and no amount of input validation changes that — the value is a perfectly well-formed UUID, it is simply somebody else's.

The variant that survives review longest is the one where the check exists but is incomplete: the handler verifies the user is a member of some organisation, or verifies membership on the parent resource and then loads children by ID without re-checking. Nested resources are where this concentrates, because the parent check feels like it covered the request.

Where It Hides

Five places, in rough order of how often we find them.

Nested resources. /orgs/:orgId/projects/:projectId/files/:fileId — the org is checked, the project is assumed to belong to it, and the file is loaded by ID alone. An attacker keeps their own org and project in the URL and swaps only the file.

Exports and reports. Bulk endpoints are written later, by someone optimising a query, and often bypass the ORM scoping used elsewhere. A CSV export that takes a date range and an org ID from the request is the highest-yield version of this bug, because one call returns everything.

Background jobs and webhooks. Anything running with a service identity has no session to scope against, so the tenant necessarily comes from the payload. That is legitimate, and it means the payload has to be verified — a webhook handler that trusts an org ID in an unsigned body is an isolation bug with extra steps.

Search and autocomplete. Search indices are frequently built without a tenant field, or with one that the query layer forgets to filter on. The endpoint returns names and email addresses across customers, and because it returns fragments rather than records, it rarely gets tested.

Support and impersonation tooling. Internal admin surfaces are built to cross tenant boundaries by design. The question is whether crossing requires elevated authorisation and produces an audit trail, and in early-stage products the answer is often that any staff account can read anything, silently.

What It Means When It Fails

A single vulnerable endpoint is usually enough for full customer data exposure, because tenant identifiers are enumerable in practice: they leak in invitation emails, in export filenames, in webhook payloads, in support tickets, and in your own frontend when a user belongs to more than one organisation. An attacker does not need to guess UUIDs blindly if your product hands them out.

The regulatory consequence is worse than the technical one. Under GDPR this is unauthorised disclosure of personal data across controllers, and your contracts almost certainly promise logical separation between customers — which means one finding creates a notification question, a contractual question, and a trust question at once. Enterprise customers ask specifically about tenant isolation in security reviews for exactly this reason.

And unlike most vulnerability classes, exploitation leaves normal-looking logs. The requests are authenticated, well-formed and successful. Unless you log the relationship between the session tenant and the requested tenant, your own telemetry will not show it happened.

How to Test It Yourself

You need two accounts in two different organisations. That requirement is the whole reason this class goes untested: with one account, the test cannot be expressed.

Then walk your API surface with account A, replacing every identifier with one belonging to organisation B:

# A's session, B's invoice
curl -H "Authorization: Bearer $TOKEN_A" \
  https://api.example.com/v1/invoices/$INVOICE_ID_B

# A's session, B's org in the path
curl -H "Authorization: Bearer $TOKEN_A" \
  https://api.example.com/v1/orgs/$ORG_ID_B/members

# A's session, B's org in a body field
curl -X POST -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
  -d '{"orgId":"'$ORG_ID_B'","format":"csv"}' \
  https://api.example.com/v1/reports/export

A 200 is a critical finding. A 404 is a pass only if it is a deliberate 404 — many applications return 404 for "not in your tenant", which is good practice, and others return 404 because the record genuinely was not found, which tells you nothing. Verify with a record you know exists.

Do it for every verb, not just GET. Update and delete paths are frequently less carefully scoped than reads, and a cross-tenant delete converts a confidentiality bug into an availability one.

The Fix, and Why the Obvious One Is Not Enough

Adding a membership check to the vulnerable handler fixes that handler. It does nothing for the next one somebody writes, which is why this class recurs in the same codebase for years.

What holds is making the tenant impossible to supply. Derive it from the session at the edge of the request and put it in a context object; make your data layer require that context, so a query without a tenant scope fails to compile or throws rather than returning everything. In Postgres, row-level security enforced with a per-request role gives you the same property at the database, which is stronger because it survives an ORM mistake.

Then close the paths that bypass all of it: bulk exports go through the same scoped layer, search indices carry a tenant field that the query builder always applies, background jobs verify the tenant in a signed payload rather than trusting it, and support tooling requires elevated authorisation and writes an audit entry naming the staff member and the tenant they crossed into.

Finally, test it continuously rather than annually. Tenant isolation breaks with ordinary feature work — a new endpoint, a new report, an optimisation that hand-writes a query — so the check belongs in the pipeline. That is the case for continuous testing in multi-tenant SaaS, and it is the single most valuable test you can automate.

Why Scanners Never Find This

Because there is nothing wrong with the request. It is authenticated, the identifier is valid, the response is a legitimate representation of a real record. A signature engine has no way to encode "this record belongs to a different customer" — that fact lives in your database and your business model, not in the HTTP exchange.

Which is also why a green scan report is not evidence of isolation. Passing every automated check while one customer can read another's invoices is not a contradiction; it is the expected outcome of testing tools that cannot hold two accounts. Anything that claims to cover this class has to authenticate as multiple tenants and attempt the crossing — human, or an agent doing what the human would.

Frequently Asked Questions

Welche Arten von Sicherheitslücken erkennt Penetrify?

Penetrify erkennt alle OWASP-Top-10-Schwachstellenkategorien, darunter SQL-Injection, XSS, CSRF, IDOR, fehlerhafte Authentifizierung, Sicherheitsfehlkonfigurationen und die Offenlegung sensibler Daten. Es testet auch die API-Sicherheit, das Session-Management und häufige Fehlkonfigurationen in Supabase, Firebase und Bubble.

Wie lange dauert ein KI-Penetrationstest?

Ein Quick-Scan ist in 15–30 Minuten abgeschlossen. Ein Standard-Scan läuft 1–2 Stunden mit breiterer Abdeckung. Ein Deep-Scan kann für komplexe Anwendungen mehrere Stunden dauern.

Was enthält ein Penetrify-Bericht?

Jeder Bericht enthält eine Executive Summary, einen Gesamtsicherheitsscore, nach Schweregrad klassifizierte Befunde (Kritisch, Hoch, Mittel, Niedrig), schrittweise Reproduktionsschritte und konkrete Abhilfemaßnahmen – geschrieben für Entwickler, nicht für Compliance-Beauftragte.

Related articles

The Unverified Stripe Webhook: Forging checkout.session.completed for Free Premium
Your webhook endpoint is a public URL that grants entitlements. Without signature verification it accepts a payment confirmation from anyone — and the request that upgrades an attacker to your top tier is a single curl command.
SQL Injection: Der umfassende Leitfaden zu Angriffen & Prävention
Dieses nagende Gefühl, wenn man sich fragt, ob die eigenen Datenbankabfragen wirklich sicher sind, kennen viele Entwickler nur allzu gut. Eine einzige, ungeprüfte Benutzereingabe kann für einen Angreifer ausreichen, um die Abwehrmechanismen Ihrer Anwendung auszuhebeln und ein simples Login-Formular in eine verheerende Datenpanne zu verwandeln. Diese Angst…
Was ist ein Pen-Test? Eine Schritt-für-Schritt-Anleitung zur Funktionsweise
Ist Ihre Webanwendung wirklich sicher? Der Gedanke an eine einzige, verborgene Schwachstelle, die zu einer katastrophalen Datenpanne führt, reicht aus, um jeden Gründer nachts wach zu halten. Sie wissen, dass Sie handeln müssen, aber die Welt der Cybersicherheit kann wie ein einschüchterndes Labyrinth aus verwirrendem Jargon und teuren Beratern wirken.

Explore more