Authorization
Isolate tenants and bulk actions
Carry trusted tenant context through queries, caches, jobs, and batch operations so one organization cannot affect another.
In multi-tenant software, one organization must never see another’s data. That’s a single rule, but you have to repeat it everywhere. One missing tenant condition can expose many records at once. That’s why tenancy bugs produce the worst incident reports.
A tenant is the organization an account belongs to. Every query, cache entry, file path, and job needs to know which tenant it’s working for.
Carry tenant context everywhere
Take the tenant from trusted membership data, never from the request alone. Then put it in storage queries, cache keys, object paths, queue messages, and logs.
The cache is the classic gap. Your database query is correct, and the cache still leaks:
// wrong: tenant-blind key, first tenant to cache wins
const cached = await cache.get(`invoice:${id}`)
// right: tenant is part of the key
const cached = await cache.get(`tenant:${tenantId}:invoice:${id}`)
With the first key, whoever requests invoice:193 first fills the cache. Everyone after that gets the same record, no matter which tenant they belong to.
Object storage paths work the same way. Write exports/acme/2026-08.csv, never a shared exports/2026-08.csv that the next tenant’s download link can reach.
Bulk operations check every item
Bulk endpoints love to check the first item and trust the rest. A bulk export validates the first invoice ID, then loads the remaining IDs with no tenant filter.
Put the constraint in the query, so every ID is checked:
-- every ID is constrained, not just validated up front
SELECT id, total FROM invoices
WHERE id = ANY(?) AND tenant_id = ?
If the query returns fewer rows than IDs requested, something in the batch was foreign. Decide what happens then. Skip silently, or fail the whole request. Either way, log it. A batch with foreign IDs in it is worth knowing about.
Background jobs inherit the boundary
A background job must carry its tenant from the moment it’s created to the moment it delivers. If the worker rebuilds the tenant from job data the client controlled, you moved the authorization bug into another process.
The API writes the job record. The worker trusts only what the API wrote:
{ "job": "export-invoices", "tenantId": "acme", "requestedBy": "usr_8817" }
The worker reads tenantId from this record, which the API set at enqueue time from the authenticated session. It never reads a tenant from anything the client sent alongside the request.
Try this on your own system: request one bulk export that mixes valid and foreign invoice IDs. Put the foreign ID first. Then prove the job, the cache, the stored file, and the download path expose nothing that belongs to the other tenant.
Lesson completed