Skip to content

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);
await client.query('SELECT set_config($1, $2, true)', ['app.tenant_id', req.tenantId])

Docs: PostgreSQL row security policies · node-postgres

Bypasses to check

  • Tenant read from anything the caller controls: header, query, body, subdomain taken from the Host header, 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-tenant
  • find(, findOne(, aggregate(, updateMany(, deleteMany(, populate(
  • req.headers[, req.query., req.body. next to a tenant field

Resources