Tenant Security: Preventing Cross-Tenant Data Leakage

Identify the specific places a multi-tenant PHP application can leak one tenant's data to another — queries, caches, background jobs, object storage, and logs — and apply structural safeguards at each layer.

In a multi-tenant system, no bug class is more damaging than cross-tenant data leakage: tenant A, through some combination of a missing filter, an unnamespaced cache key, or a background job that lost track of which tenant it was working for, ends up seeing or receiving tenant B's data. It doesn't matter how good the rest of the application is — one confirmed instance of this bug, disclosed to a customer or a regulator, can end a SaaS company's relationship with that customer instantly and does lasting reputational damage industry-wide. This lesson goes layer by layer through where these leaks actually happen in real systems and how to close each one structurally, rather than relying on every developer remembering every time.

Why This Is the Scariest Bug Class

Most bugs in a web application have a bounded, visible blast radius: a broken feature affects the user who hit it, an error page is embarrassing but contained. A cross-tenant leak is different in kind: it's a confidentiality breach, potentially covered by contractual data-protection obligations and regulations (GDPR, SOC 2, HIPAA depending on the domain), and it can have happened silently for a long time before anyone notices, because the application 'worked' the entire time from every individual tenant's point of view — right up until the moment someone notices data that isn't theirs.

It's also uniquely hard to catch with conventional testing, because a test suite that only ever exercises one tenant's data at a time will never surface a missing tenant filter — the query returns exactly the expected rows in isolation, since there's nothing else in the test database to leak. Reliably preventing this requires structural safeguards that make the unsafe path hard or impossible to write, rather than relying on code review or testing discipline to catch every instance across a growing codebase and team.

• A cross-tenant leak is a confidentiality breach with contractual/regulatory exposure, not just a functional bug. • It's invisible from any single tenant's perspective — everything 'works' until someone notices data that isn't theirs. • Single-tenant test data can't surface a missing tenant filter — testing alone doesn't reliably catch this class of bug. • The fix has to be structural (hard to write incorrectly) rather than purely disciplinary (remembering to write it correctly).

Query Scoping

As covered in the previous lesson, the shared-tables multi-tenancy model puts the entire isolation burden on every query correctly filtering by tenant_id. The structural fix is to make that filter automatic rather than optional: route all tenant-scoped reads and writes through a base repository, a query builder wrapper, or an ORM's global scope mechanism that injects the tenant filter itself, so an individual call site has no opportunity to forget it — there's no WHERE tenant_id = ? for a developer to type (and potentially mistype or omit) at every call site, because the access layer adds it unconditionally.

This only works if it's genuinely unconditional — if there's an escape hatch that lets a call site bypass the scoped access layer 'just this once' for convenience, that escape hatch is exactly where the eventual leak will happen, usually in an urgent, under-reviewed hotfix written by someone who didn't know (or forgot) the escape hatch skips tenant scoping. Any bypass mechanism, if it must exist at all (for legitimate cross-tenant admin tooling, say), should require an explicit, auditable, differently-named method — never the default path — precisely so it stands out in code review.

• Route every tenant-scoped query through a base repository/global query scope that injects tenant_id automatically. • The safety only holds if it's unconditional — any 'raw query' escape hatch becomes the eventual leak site. • Legitimate cross-tenant admin access should use an explicitly, differently-named method that stands out in review, never the default path.

Cache Key Namespacing

A shared Redis (or Memcached) cache sitting in front of a shared database is a common and reasonable architecture — but every cache key derived from tenant-scoped data must include the tenant ID as part of the key itself, not just the resource ID. A cache key like user:42:profile is dangerously ambiguous in a multi-tenant system if user IDs aren't globally unique across tenants (or even if they are, if the CACHED VALUE itself could vary by which tenant's context computed it) — the fix is a key like tenant:{$tenantId}:user:42:profile, so tenant A's cache reads and writes can never collide with tenant B's, structurally, at the key-naming level.

This matters even when the underlying IDs genuinely are globally unique (e.g. UUIDs), because the danger isn't only ID collision — it's that a cache-population code path, written and tested against one tenant's data, can be reused in a context that computes a value differently per tenant (a permission-gated field, a tenant-specific price, a feature-flagged variant) and cache the wrong tenant's computed result under a key another tenant's request later reads. Namespacing every cache key by tenant ID as a hard rule — applied even where it looks technically unnecessary for a specific key — removes the need to reason about it case by case.

• Every cache key touching tenant-scoped data must include the tenant ID, e.g. tenant:{id}:user:42:profile, not just user:42:profile. • This matters even with globally-unique IDs, because the cached VALUE (not just the key collision) can be tenant-dependent. • Apply tenant namespacing as a hard, universal rule for cache keys rather than deciding case by case whether a given key 'needs' it.

Background Jobs

A queued background job runs outside the original HTTP request that enqueued it — often on a different worker process, sometimes minutes or hours later — so it has no automatic access to whatever tenant context (a session, a request-scoped variable) identified the tenant during that original request. If a job's payload doesn't explicitly carry the tenant ID, and the job's processing code doesn't explicitly re-apply that tenant context before touching any tenant-scoped data or cache, it either fails outright (if scoping is enforced elsewhere and the context is simply missing) or — worse — silently operates in the wrong tenant's context if some ambient, incorrect default tenant context leaks in from wherever the worker process happens to be running.

The safe pattern is to always serialize the tenant ID explicitly into the job's payload when it's enqueued, and have the job handler's very first action be re-establishing that tenant's scoped context (setting it on the tenant-scoped query builder, the cache-key namespace, and any authorization checks) from that payload value — never from an ambient global, a 'currently logged in user' construct that doesn't exist in a worker process, or a leftover value from whatever job the same worker process handled previously.

• A queued job runs outside the original request and has no automatic access to that request's tenant context. • Explicitly serialize the tenant ID into the job payload at enqueue time — never rely on an ambient/global tenant context in the worker. • The job handler's first action should re-establish tenant scoping (query scope, cache namespace, authorization) from that payload value.

Object Storage

Files uploaded by tenants — documents, images, exports — need tenant-scoped storage paths or, for the strongest isolation, tenant-scoped buckets/containers entirely, so that a path traversal bug, a misconfigured access-control rule, or a simple typo in a generated URL can't reach across tenant boundaries as easily as it could if every tenant's files sat in one flat, shared path space. A convention like s3://app-uploads/{tenantId}/invoices/{invoiceId}.pdf at minimum keeps tenants' files in clearly separated prefixes, which also makes it straightforward to apply IAM/bucket policies scoped by prefix if the storage provider supports it.

Just as important: never trust a client-supplied tenant ID (from a URL parameter, a hidden form field, or even a JWT claim that the client itself constructed) as the sole basis for deciding which tenant's storage path to read or write. The tenant ID used to build a storage path must come from the SERVER's own authenticated session/context — the tenant the currently logged-in user actually belongs to, verified server-side — never from a value the request merely claims, or a malicious or simply buggy client could substitute a different tenant's ID and read or write that tenant's files directly.

• Tenant-scoped storage paths/buckets (e.g. {tenantId}/invoices/{id}.pdf) contain the blast radius of storage-layer mistakes. • Never trust a client-supplied tenant ID for building a storage path — always derive it from the server-verified authenticated session. • Where the storage provider supports it, scope IAM/bucket policies by tenant prefix as a second, infrastructure-level safeguard.

Logs

Application logs and audit trails need the same tenant-boundary discipline as data and caches, for two related reasons. First, log lines mixing multiple tenants' data in a shared, centrally-searched log store need to carry the tenant ID as a structured field, so that any tool or person querying logs for tenant A's incident can filter to exactly tenant A's log lines — without that field, distinguishing tenants in a log search requires fragile, unreliable text-matching on whatever incidental identifiers happen to appear in the message.

Second, and more seriously: if a support engineer, an internal admin tool, or a customer-facing 'export your audit log' feature ever queries logs WITHOUT correctly scoping by tenant ID, the exact same class of leak as an unscoped database query can occur — tenant A's audit-log export accidentally includes tenant B's log lines because the log query itself lacked a tenant filter. Any log-search or audit-export feature reachable by anyone below full internal-admin trust needs to apply tenant scoping to the log query itself with the same rigor as a database query — logs are not exempt from the same discipline just because they're 'just for observability'.

• Every log line touching tenant-scoped activity should carry a structured tenant_id field, not rely on incidental text matching. • Any log-search or audit-export feature must scope its query by tenant ID with the same rigor as a database query. • Treat log/audit-trail access with the same trust boundary as data access — 'just observability' is not an exemption from tenant scoping.

Code Examples

By forcing every cache operation through scopedKey(), which unconditionally prefixes the tenant ID, no call site can produce an unnamespaced cache key even by accident — the safety is structural, matching the same principle applied to query scoping.

// Support tool: export a tenant's audit log $logs = $pdo->query("SELECT * FROM audit_log WHERE action = 'export'")->fetchAll();

$logs = $pdo->prepare('SELECT * FROM audit_log WHERE tenant_id = :tid AND action = :action') ->execute(['tid' => $currentTenant->id, 'action' => 'export']);

The unscoped query returns every tenant's 'export' audit-log rows in one result set — a support engineer running this 'just to check one customer's activity' would receive, and could inadvertently expose, every other tenant's audit history too. The scoped version filters by the server-verified current tenant ID before returning any rows.

Why is a cross-tenant data leak considered a uniquely dangerous bug class compared to an ordinary functional bug? A. It always crashes the application immediately, making it easy to catch B. It's a confidentiality breach with potential contractual/regulatory exposure, and it can go unnoticed for a long time since the app 'works' from any single tenant's perspective C. It only affects performance, not correctness D. PHP applications are immune to this class of bug

B. It's a confidentiality breach with potential contractual/regulatory exposure, and it can go unnoticed for a long time since the app 'works' from any single tenant's perspective — A cross-tenant leak is a confidentiality breach, not merely a broken feature — it can persist silently because nothing looks wrong from any individual tenant's point of view, and single-tenant testing typically can't surface it.

Why must a background job explicitly carry the tenant ID in its payload rather than relying on the tenant context from the request that enqueued it? A. Because PHP queues cannot serialize integers B. Because the job runs later, often on a different worker process, with no automatic access to the original request's tenant context C. Because tenant IDs change constantly D. Because background jobs never need tenant scoping

B. Because the job runs later, often on a different worker process, with no automatic access to the original request's tenant context — A queued job executes outside the original HTTP request's lifecycle, so any tenant context that existed only in that request (session state, request-scoped variables) is gone by the time the job runs — it must be explicitly serialized into the payload and re-applied by the job handler.

What is wrong with deciding which tenant's file to read from object storage based on a tenant ID passed in a URL query parameter? A. Nothing — query parameters are always trustworthy B. URL parameters can't hold string values C. A client-supplied tenant ID can be tampered with; the tenant ID used for storage access must come from the server-verified authenticated session, not from a value the request merely claims D. Object storage doesn't support tenant-scoped paths

C. A client-supplied tenant ID can be tampered with; the tenant ID used for storage access must come from the server-verified authenticated session, not from a value the request merely claims — Trusting a client-supplied tenant ID lets a malicious or buggy client substitute a different tenant's ID and access that tenant's files directly; the tenant ID must be derived from the server's own verified session/authorization context.

<?php
declare(strict_types=1);

final class TenantCache
{
    public function __construct(private Redis $redis, private int $tenantId) {}

    private function scopedKey(string $key): string
    {
        return "tenant:{$this->tenantId}:{$key}";
    }

    public function get(string $key): mixed
    {
        $value = $this->redis->get($this->scopedKey($key));
        return $value === false ? null : json_decode($value, true);
    }

    public function set(string $key, mixed $value, int $ttlSeconds = 300): void
    {
        $this->redis->setex($this->scopedKey($key), $ttlSeconds, json_encode($value));
    }
}

// tenant 7 and tenant 9 can both cache "invoice:42:total" without ever colliding,
// because scopedKey() always prefixes with the tenant ID first.
$cacheForTenant7 = new TenantCache($redis, tenantId: 7);
$cacheForTenant7->set('invoice:42:total', 4999);