In brief: Most tenant leaks come from a missing condition or misplaced trust, not an exotic attack.
The problem
A user can be fully authenticated and still ask for another tenant's resource. Checks in the browser cannot protect the API. And a single unscoped query or object key is enough to cross the boundary.
Why the simple answer is not enough
Authentication answers who the caller is. Tenant authorization answers something else: whether that identity may act on this particular tenant, site and resource. Collapse the two into a single check, and valid credentials quietly become a cross-tenant data leak.
Isolation has to hold on every execution path — HTTP, background jobs, cache fills, exports and admin tooling. A repository helper helps, but it is not enough on its own. When a code path forgets the helper, the database itself should still refuse unscoped access.
System flow
user and site identity→Authorization
membership and permission→Tenant query
RLS plus scoped filters→Storage
server-generated key
Architecture decisions
Derive tenant context on the server
Resolve tenant membership from the authenticated principal and the requested site. Never trust a tenantId taken from a form, a JWT custom field or an object path. Always re-check current membership and resource ownership first.
Enforce scope in the database
Set a transaction-local tenant context, then apply row-level security or a mandatory composite predicate. When the context is missing, the query must return no rows or fail outright — it must never silently widen.
Carry identity into asynchronous work
Each job payload should carry the tenant, the actor, a permission snapshot or re-authorization key, and a correlation ID. Workers then establish the same database context and storage prefix as the request that created the job.
Going deeper
Authorization is a graph, not a single boolean
Access can depend on organisation membership, site role, resource ownership, delegation and the current subscription. Resolve all of these relationships into one explicit decision with reason codes. Then apply that decision to a resource query that still carries tenant and site predicates. Do not load an object globally and check it afterward: timing, error messages and side effects can already reveal that it exists. For list endpoints, authorisation has to be expressible as a set-based query. Filtering unauthorised rows in application memory is both slow and easy to bypass through counts, exports or pagination.
Treat every boundary as independently fallible
RLS protects rows, but not object storage, search indexes, logs or cache entries. Generate storage keys from server-owned tenant identifiers, issue short-lived signed URLs, and validate an uploaded object's metadata before publishing it. Search documents need tenant fields enforced by a wrapper that callers cannot skip. Logs should carry tenant and actor for audit, but redact secrets and sensitive payloads. Encryption keys can be shared with strong logical isolation, or dedicated per tenant for higher assurance. That choice affects rotation, recovery, cost and the blast radius of an incident.
Implementation path
- Bind the requested site to the session's tenant before anything else.
- Set tenant context in the database, and fail closed whenever that context is missing.
- Generate storage paths on the server, and hand out short-lived upload and download URLs.
Technical example: Transaction-scoped tenant context
BEGIN
SET LOCAL app.tenant_id = :tenantId
SET LOCAL app.actor_id = :actorId
SELECT * FROM documents
WHERE site_id = :siteId AND id = :documentId
-- RLS policy also requires:
tenant_id = current_setting('app.tenant_id')
COMMITUse a transaction-local setting so that a pooled connection cannot leak tenant context into the next request. Service roles that bypass RLS should be rare, isolated and audited.
Failure modes to design for
- An object ID is globally unique, so a developer queries by ID alone. But uniqueness is not authorization — the tenant scope still has to be present.
- A cache key holds the resource ID but not the tenant or the permission version. The cache quietly turns into a cross-tenant read path.
- An export job is authorized when it is queued, but permissions change before it runs. Re-authorize sensitive jobs at run time, or use an explicit, short-lived grant.
What to observe
| Signal | Why it matters |
|---|---|
| Denied cross-tenant attempts | A sudden rise can mean probing, broken links, or a client sending stale site context. |
| Queries missing tenant context | Instrument the database boundary and treat every occurrence as a security defect. |
| RLS policy test coverage | Tracks whether every tenant-owned table has both positive and negative isolation tests. |
| Privileged-role usage | Unexpected bypass-role activity should raise a high-priority audit event. |
How to validate the design
- Build an access matrix covering owner, member, invited user, removed user and service account, across two tenants.
- Fuzz every resource endpoint by swapping the tenant, site and object identifiers independently.
- Run background jobs and exports after revoking the initiating user's role, to verify the re-authorisation policy you chose.
- Inspect cache, search and object-storage keys during tests; database isolation alone is not enough evidence.
- Use a connection-pool stress test to prove that transaction-local RLS context never leaks between concurrent requests.
Safe rollout plan
Where you can, introduce database enforcement in report-only mode first: log the queries that would violate the future policy, without blocking production. Fix the call sites, add negative integration tests, then enable RLS table by table, starting with low-traffic data. Keep a narrowly scoped break-glass role that requires approval, expiry and audit. During rollout, compare API denial rates against support tickets, and stop if valid access changes unexpectedly. A security control that operators routinely bypass is weaker than one that was rolled out carefully.
Production checklist
- Duplicate checks across layers are deliberate defence in depth, not waste.
- Cache keys and background jobs need the same tenant context as HTTP requests.
- Treat cross-tenant access as a first-class security test, not an afterthought.
Takeaway
Tenant isolation is a chain. One missing link is enough to turn valid access into a data leak.
