Skip to content

How-to: Tenant Isolation & Cross-Tenant IDOR Prevention

FT179 — isolationlog

Preventing cross-tenant data leakage in multi-tenant APIs — scoped SQL queries, header-based identity, and body injection prevention.


The threat: Cross-Tenant IDOR

In a multi-tenant system, every resource belongs to a tenant. An attacker who controls one tenant account probes IDs from other tenants:

GET /notes/42          X-Tenant-Id: 2   ← attacker is tenant 2
                                         note 42 belongs to tenant 1

If the server returns the note, the attacker has read another tenant's data — an Insecure Direct Object Reference (IDOR) at the tenant boundary.


The isolation pattern

1. Scope all reads at the SQL level

Never query by ID alone. Always add AND tenant_id = ?:

php
// ❌ WRONG — ID alone, cross-tenant readable
'SELECT * FROM notes WHERE id = ?'

// ✅ CORRECT — ID + tenant enforced in SQL
'SELECT * FROM notes WHERE id = ? AND tenant_id = ?'

This returns null for cross-tenant access, which becomes a 404. The attacker learns nothing about note 42 — not even whether it exists.

2. List queries are always scoped

php
// ❌ WRONG — could be augmented with ?tenant_id=... injection
'SELECT * FROM notes ORDER BY id DESC LIMIT ?'

// ✅ CORRECT — WHERE tenant_id = ? is never optional
'SELECT * FROM notes WHERE tenant_id = ? ORDER BY id DESC LIMIT ?'

3. Delete uses the same pattern

sql
DELETE FROM notes WHERE id = ? AND tenant_id = ?

rowCount() returns 0 if the note doesn't belong to the tenant → 404.


Header-based tenant identity

Use X-Tenant-Id + X-User-Id headers for tenant-scoped endpoints. Validate both with V::userId() (ctype_digit + overflow guard + > 0):

php
private function resolveTenantUser(ServerRequestInterface $request): array
{
    $tenantId = V::userId($request->getHeaderLine('X-Tenant-Id'));
    $userId   = V::userId($request->getHeaderLine('X-User-Id'));

    return [$tenantId, $userId];
}

V::userId() rejects:

  • Empty string (ctype_digit('') === false)
  • Zero (id <= 0)
  • Negative ('-' fails ctype_digit)
  • Float string ('1.5' fails ctype_digit)
  • 20+ digit overflow (strlen > 18 guard)
  • SQL injection attempts ('1 OR 1=1' fails ctype_digit)

Body injection prevention

Attackers may include tenant_id in the POST body to try to assign a resource to a different tenant:

json
POST /notes
X-Tenant-Id: 1
{ "content": "Injection", "tenant_id": 99 }

Never read tenant_id from the body. Always use the server-validated header:

php
// ATK-04: body['tenant_id'] is never read — always use $tenantId from header
$note = $this->notes->create($tenantId, $userId, $content, date('c'));
//                            ^^^^^^^^^
//                            from V::userId(X-Tenant-Id), not from $body

Tenant existence check on write

Before creating a resource, verify the tenant exists:

php
if (!$this->tenants->exists($tenantId)) {
    return $this->responseFactory->create(['error' => 'Tenant not found.'], 422);
}

Without this check, notes would be created for ghost tenant IDs that don't exist in the tenants table, breaking referential integrity.


Attack checklist (ATK-01 through ATK-12)

#TestExpectation
ATK-01No auth headers401
ATK-02Cross-tenant GET (IDOR)404 — note exists but not for this tenant
ATK-03X-Tenant-Id: "1", 1.5, +1, 1 OR 1=1401 — V::userId rejects
ATK-04POST body contains tenant_id: 99201 — body tenant_id ignored
ATK-05Cross-tenant DELETE404 — note not deleted
ATK-06X-Tenant-Id: 0, -1401
ATK-07X-Tenant-Id: 20-digit overflow401
ATK-08Tenant creation without X-Admin-Key401
ATK-09Wrong X-Admin-Key401
ATK-10Note for non-existent tenant ID422
ATK-11List: T1 sees only T1 notes, not T2'sEnforced by SQL WHERE tenant_id
ATK-12?limit=-1, ?limit=10.5, 20-digit limit422 — V::queryInt guards

Response strategy: 404 not 403

When a cross-tenant IDOR is detected, return 404 — not 403 Forbidden.

  • 403 leaks existence: "the resource exists but you can't access it"
  • 404 reveals nothing: "no such resource for this tenant"

This prevents tenant enumeration attacks.

Released under the MIT License.