Broken tenant isolation¶
When is it relevant¶
The app serves several tenants (organizations, companies, workspaces, projects) from shared code or data, and the change adds or modifies something that touches tenant-owned data or tenant membership: an entry point, query, background job, cache or file path.
Attack¶
A member of tenant A reaches tenant B's data or actions. Typical ways in:
- Passing one of B's resource IDs to a route that checks only the caller's role or account type, not the resource's tenant.
- Switching tenants with a header or parameter that isn't checked against membership.
- A query, job or cache key that is missing the tenant filter.
- Obtaining a credential or token that acts inside B.
Mitigation¶
- Derive the tenants a caller may act in from their authenticated identity (membership), never from client input alone.
- On every lookup of a tenant-owned resource, check that the resource's tenant is one the caller belongs to, before reading or acting.
- Apply the tenant filter centrally (repository or query helper), including keys, tokens, files, jobs and caches.
- Bind issued credentials to the tenant they were created in.
Examples¶
Mitigation options
| Option | When it fits | Docs |
|---|---|---|
| Tenant in every query filter | Small codebases; explicit at each call site | Mongoose queries |
| Tenant-scoped repository or Mongoose plugin | Many handlers; one place to get it right | Mongoose plugins |
| Database row-level security | Postgres, and you want the DB to enforce it | node-postgres |
Vulnerable — tenant taken from the request
router.get('/invoices', requireAuth, async (req, res) => {
const tenantId = req.headers['x-tenant-id'] || req.query.tenantId
res.json(await Invoice.find({ tenantId })) // caller picks the tenant
})
Vulnerable — object ID trusted once authenticated
router.get('/invoices/:id', requireAuth, async (req, res) => {
res.json(await Invoice.findById(req.params.id)) // any tenant's invoice
})
Mitigated — filter on the server's copy of the tenant
function tenantContext(req, res, next) {
req.tenantId = req.user.tenantId // from the session, never the request
if (!req.tenantId) return res.sendStatus(403)
next()
}
router.get('/invoices/:id', requireAuth, tenantContext, async (req, res) => {
const invoice = await Invoice.findOne({ _id: req.params.id, tenantId: req.tenantId })
if (!invoice) return res.sendStatus(404)
res.json(invoice)
})
Mitigated — scoped repository
export function invoicesFor(tenantId) {
const scope = { tenantId }
return {
list: (q = {}) => Invoice.find({ ...q, ...scope }), // scope wins over q
byId: (id) => Invoice.findOne({ _id: id, ...scope }),
update: (id, u) => Invoice.updateOne({ _id: id, ...scope }, u),
}
}
Spreading the scope last matters: { ...scope, ...q } would let a caller
pass tenantId in q and override it.
Docs: Mongoose queries
Mitigated — row-level security
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id')::uuid);
Docs: PostgreSQL row security policies · node-postgres
Bypasses to check
- Tenant read from anything the caller controls: header, query, body, subdomain
taken from the
Hostheader, or a JWT claim that isn't verified. - A query that filters by ID only, on the assumption that IDs are unique and unguessable (UUIDs leak through exports, webhooks and error messages).
- Aggregations,
populate(),JOINs, counts, search indexes and report queries that drop the tenant filter the simple finder has. - Bulk and admin paths:
updateMany,deleteMany, CSV export, background jobs that iterate all rows. - Caches keyed without the tenant, so one tenant's response is served to another.
- Check then change: membership verified for the tenant in the session,
then the write uses
req.body.tenantId. - Cross-tenant references accepted as input: moving a record into another tenant's project, or inviting by an ID that belongs elsewhere.
What to grep for
tenantId,orgId,workspaceId,accountId,x-tenantfind(,findOne(,aggregate(,updateMany(,deleteMany(,populate(req.headers[,req.query.,req.body.next to a tenant field