Hiding another company’s records in the UI is not tenant isolation.
It is a filter.
Real isolation has to survive a user changing a URL, replaying a request, guessing an ID, running a background job, opening an exported file, or hitting a stale cache.
That means multi-tenant security begins below the interface.
Establish tenant context from trusted identity
The client can request a tenant.
The server decides whether that identity belongs to it.
I want a request path like:
- verify the session or token;
- resolve the actor;
- validate tenant membership and role;
- create a trusted tenant context;
- pass that context into domain operations.
I do not trust a browser field such as:
{ "tenantId": "company-b" }
The value can help identify intent. It cannot prove permission.
Scope every read
A dangerous pattern looks harmless:
const project = await Project.findById(projectId);
The ID may be valid.
It may belong to another tenant.
The query needs the ownership boundary:
const project = await Project.findOne({
_id: projectId,
tenantId: context.tenantId,
});
The same rule applies to list, count, aggregate, search, export, and report paths.
One unscoped helper can bypass a perfectly designed route guard.
Scope writes and the read before the write
Updating by ID alone is unsafe.
So is loading a record without tenant scope, checking something in application code, and later writing with a different query.
Prefer one ownership-aware operation where possible:
const updated = await Project.findOneAndUpdate(
{
_id: projectId,
tenantId: context.tenantId,
},
{
$set: allowedChanges,
},
{ new: true },
);
Then return a controlled not-found or forbidden result without leaking whether another tenant owns the ID.
Uniqueness is usually tenant-relative
An email, project code, slug, invoice number, or integration key may need to be unique inside one tenant, not across the whole database.
That becomes a compound constraint:
schema.index(
{ tenantId: 1, projectCode: 1 },
{ unique: true },
);
The application validation and the persisted index should express the same rule.
Otherwise concurrent requests can pass the friendly pre-check and still create duplicate state.
Caches need tenant keys
This cache key is incomplete:
`dashboard:${userId}`
If the same user belongs to more than one tenant, cached data can cross contexts.
Use the full ownership scope:
`tenant:${tenantId}:user:${userId}:dashboard:v2`
Invalidation must use the same dimensions.
The same warning applies to memoization, CDN responses, request caches, and generated exports.
Jobs, webhooks, and files are tenant paths too
Async systems are where isolation is often forgotten.
Every job should carry a validated tenant reference and re-check the resource when it executes.
Every webhook should map the provider object to an internal tenant through server-side data—not a client-supplied label.
Every storage path, signed URL, export, and attachment lookup should enforce ownership.
Moving work off the request thread does not move it outside the security model.
Administrators need explicit boundaries
“Admin” is not one universal role.
Separate:
- tenant administrator;
- support operator;
- platform administrator;
- background system actor.
Platform access should be rare, auditable, and deliberately invoked.
Do not let a broad internal role become the default shortcut around tenant checks.
Negative tests prove the boundary
A happy-path test says Company A can read Company A’s project.
A security test says Company A cannot:
- read Company B’s project;
- update it;
- delete it;
- export it;
- infer it through a count;
- receive it from cache;
- trigger a job against it;
- reach its file.
I prefer reusable cross-tenant test helpers so every new resource gets the same hostile checks.
Important: authentication proves who the actor is. Tenant authorization proves which organization and resource that actor may access. Both checks are required.
Observe without leaking
Logs should contain enough context to investigate:
- request or trace ID;
- internal actor ID;
- tenant ID;
- operation;
- authorization decision;
- safe error code.
They should not contain session tokens, provider secrets, private file contents, or unnecessary personal data.
Security and privacy apply to observability too.
The foundation checklist
Before calling a SaaS tenant-safe, I trace:
- identity to membership;
- membership to trusted context;
- context to every read and write;
- tenant-relative constraints;
- cache keys and invalidation;
- async jobs and webhooks;
- file ownership;
- admin escalation;
- negative tests;
- safe audit events.
The interface is the final layer.
The boundary lives underneath it.
For API behavior around these operations, read API contracts that survive product growth. For production review, use my code-review checklist.
If your SaaS already exists, send me one protected resource and its read/write path. That is enough to begin an isolation audit.
- #SaaS
- #Multi-Tenant
- #Security
- #Authorization
- #Data Isolation

Continue reading
Related field notes on engineering, SaaS, freelancing, and building dependable products.