Multi-Tenant Authorization and Data Isolation

In a shared multi-tenant system the most expensive failure is not a privilege escalation inside one customer — it is one customer seeing another’s data, which is a breach notification rather than a bug report. This guide is part of the access control and authorization guide, and it covers where the tenant boundary lives, how to make it structurally hard to cross, and how to prove it holds.

Prerequisites

  • A working authorization model — roles with scopes, as in designing role-based access control systems, or attribute rules.
  • Sessions that can carry a tenant claim, established at login rather than supplied per request.
  • A database that supports row-level security, or a data-access layer you control end to end.

The Tenant Identifier Has Exactly One Source

Every cross-tenant leak traces back to a tenant identifier that came from somewhere the caller controlled. The rule is absolute: the tenant is established at login, stored in the session, and read from there on every request.

Trusted and untrusted sources for the tenant identifier The verified session is the only trustworthy source; a header, query parameter, request body field or subdomain supplied by the client are all attacker-controlled and must be validated against the session rather than trusted. Trusted the verified session record set at login, changed only by an explicit, audited switch one source, server-side Untrusted X-Tenant-Id header ?tenant= query parameter tenantId in the request body validate against the session, never trust
A subdomain is a routing hint, not an authorization input: it tells you which tenant the user meant, and the session tells you which one they may have.

Users who belong to several tenants make this interesting. Model tenant switching as an explicit action that mints a new session — or at minimum rewrites the tenant on the existing one and regenerates its identifier — and audit it. A request parameter that silently changes which customer’s data you are looking at is indistinguishable from an attack.

Enforce at the Data Layer, Not Only Above It

Application-level filtering is necessary and insufficient, because it depends on every query remembering. Row-level security moves the predicate into the database, where a forgotten clause cannot leak data.

ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;   -- applies to the table owner too

CREATE POLICY tenant_isolation ON invoices
  USING      (tenant_id = current_setting('app.tenant_id')::uuid)
  WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);

Two details separate a working policy from a decorative one. FORCE ROW LEVEL SECURITY makes the policy apply to the table’s owner, which is the role most applications connect as — without it, the policy silently does nothing in production. And WITH CHECK governs writes: with only USING, a user can read just their own rows while inserting rows labelled with another tenant’s identifier, which is a data-poisoning path rather than a read leak but is no less serious.

Set the tenant inside the transaction with SET LOCAL, never at connection time. Connection pools hand the same physical connection to different requests, so a session-level setting leaks one tenant’s context into another’s query — a bug that appears only under concurrency, which is the worst possible way to find it.

Why the tenant setting must be transaction-scoped A connection-level setting persists after the request returns the connection to the pool, so the next request from another tenant inherits it; a transaction-scoped setting is reset when the transaction ends. SET (connection level) request A sets tenant = acme connection returns to the pool request B inherits acme SET LOCAL (transaction) scoped to this transaction reset automatically at commit no leakage between requests
This is the highest-severity, lowest-visibility bug in the whole pattern: correct under test, wrong under load.

Caches, Queues and Everything Else With a Key

The database is not the only place tenant data lives. Every cache key, every queue message, every search index document and every uploaded file path needs the tenant in it, and the failure mode is identical each time: a key that omits the tenant serves one customer’s data to another.

Put the tenant identifier first in every cache key so a prefix scan is scoped by construction, and make the key builder a single shared function rather than a string template repeated across the codebase. For asynchronous work, carry the tenant explicitly in the message payload rather than inferring it from the job’s contents, and re-establish it — and the row-level security setting — at the start of the handler, because a background worker has no session to inherit from.

Object storage deserves specific attention. Prefix paths with the tenant, generate signed URLs scoped to a single object rather than a prefix, and keep expiry short. A signed URL that grants access to a prefix is a cross-tenant read waiting for a path traversal.

Tenant-Scoped Roles and Cross-Tenant Access

A role is meaningless without a scope, and in a multi-tenant system the scope is at minimum the tenant. Store grants as (user, role, tenant) rather than (user, role), and make the tenant a required column so a grant with no scope cannot be written.

Two roles need special handling. Tenant administrators can manage users and settings inside their own tenant and must never be able to reach outside it, which means their capabilities are checked against the session’s tenant like everyone else’s. Support and operations staff sometimes genuinely need cross-tenant access, and that path deserves to be built deliberately: an explicit impersonation or support-access mode, time-boxed, approved, announced to the customer where your contracts require it, and written to the permission audit log with the reason.

Three access categories in a multi-tenant system Ordinary users act only within their session's tenant, tenant administrators manage that tenant, and support access crosses tenants only through an explicit, time-boxed and audited mode. User one tenant per session switching mints a new session Tenant admin manages their tenant still bounded by the session's tenant Support access explicit, time-boxed approved, audited, visible to the customer
The right-hand column is the only legitimate way across the boundary, and building it properly is what stops people building it badly under time pressure.

Proving Isolation Holds

Isolation is a property you assert continuously, not a design you finish. Three test shapes cover most of it.

A two-tenant fixture is the foundation: create identical data under two tenants, authenticate as a user of the first, and assert that every list endpoint returns only their rows and every direct fetch of the other tenant’s identifier returns 404. Run it against every endpoint automatically, because the gap is always the endpoint added last week.

A pooled-connection test catches the SET LOCAL bug: issue concurrent requests from two tenants against a pool with a single connection and assert that neither sees the other’s rows. Without concurrency in the test, this passes trivially and fails in production.

A background-worker test asserts that asynchronous jobs re-establish the tenant context rather than inheriting whatever the last job set, which is the same class of bug one layer removed from the request path.

Add a data-level invariant check that runs on a schedule: for each table, count rows whose tenant identifier does not match their parent’s. A non-zero result means something wrote across the boundary, and finding that from a query beats finding it from a customer.

Frequently Asked Questions

Should each tenant get its own database?

It depends on how strong your isolation promise is and how many tenants you have. A database per tenant gives the cleanest boundary and the simplest compliance story, at the cost of migrations across many databases and a slow path to onboarding. A shared database with row-level security scales to far more tenants and requires the discipline described here. Most products start shared and offer dedicated databases as an enterprise option, which is a reasonable place to end up.

Is row-level security enough on its own?

It is the backstop, not the whole design. Keep tenant filtering in your data-access layer too, so queries are efficient and intent is visible in the code, and keep the row-level policy as the thing that makes a forgotten filter harmless rather than catastrophic. Belt and braces is the right posture for the one boundary whose failure is a breach.

How do I handle a user who belongs to several tenants?

Treat the tenant as part of the session rather than part of the user. Switching becomes an explicit action that regenerates the session identifier and writes an audit event, and every request unambiguously belongs to exactly one tenant. Allowing a per-request tenant parameter is the shortcut that turns every endpoint into a potential cross-tenant read.

What about shared reference data?

Keep it in tables that are explicitly global and make that explicit in the schema — a separate schema or a naming convention — so nobody has to guess whether a table is tenant-scoped. The dangerous shape is a table that is mostly global with a few tenant-specific rows and a nullable tenant column, because “null means everyone” is a rule that will eventually be forgotten in a query.

How should support staff access customer data?

Through an explicit support mode: time-boxed, approved by a second person for sensitive tenants, logged with a reason, and — where your contract or regulator requires it — visible to the customer. It should mint a session marked as impersonation so every downstream audit event carries that fact. What you want to avoid is a permanent cross-tenant role held by a support team, because it is indistinguishable from an attacker who compromises one of those accounts.

Choosing an Isolation Model

Three models dominate, and the choice affects everything from onboarding speed to how you answer a security questionnaire.

A shared database with a tenant column is the default for products with many tenants. Onboarding is a row, migrations run once, and utilisation is efficient. The isolation is entirely a property of your code and your row-level policies, which is why the discipline in this guide matters. It is the right answer for the large majority of SaaS products.

A schema per tenant keeps one database but gives each tenant its own namespace. Queries are naturally scoped by the search path, and a mistake is likelier to produce an error than a leak. The cost is migrations that must run across every schema and a practical ceiling of a few thousand tenants before tooling starts to strain.

A database per tenant gives the cleanest boundary and the easiest compliance story: separate credentials, separate backups, and the ability to host a customer in a region they specify. It also gives you the slowest onboarding, the most operational surface, and a migration story that has to be reliable across hundreds of targets. It is usually offered as an enterprise tier rather than adopted universally.

Whichever you choose, write down which one you are promising to customers, because “isolated” means different things to a procurement questionnaire and to your database. Products routinely run shared infrastructure with strong logical isolation and say so plainly; the problems start when the marketing claim and the architecture diverge.

Onboarding, Offboarding and Data Lifecycle

Isolation is also a lifecycle property. Onboarding a tenant should be a single transactional operation that creates the tenant record, its first administrator, and any default configuration — partially created tenants are a reliable source of confusing authorization failures, because a user exists with a tenant that has no settings.

Offboarding is the harder half. When a customer leaves, you need to be able to export everything belonging to that tenant and then delete it, including the copies in caches, search indexes, object storage, backups within your retention window, and any analytics store. That is only achievable if every one of those systems carried the tenant identifier from the start, which is another reason the key-hygiene rules above are worth enforcing early. Write the deletion procedure while you are building, not when the first cancellation arrives, and test it by onboarding a throwaway tenant, filling it with data, and deleting it.

Keep a per-tenant inventory of where data lives — table groups, buckets, indexes, queues — and treat adding a new store without adding it to that inventory as an incomplete change. The inventory is what makes both a deletion request and a security questionnaire answerable in an afternoon rather than a fortnight.

Noisy Neighbours and Per-Tenant Limits

Isolation is not only about data. In a shared system one tenant’s traffic affects everyone else’s latency, and a tenant running an unusual workload — a bulk import, a runaway integration, an accidental infinite loop in their own automation — can degrade the product for people who have no idea they exist.

Apply rate limits per tenant rather than only per user or per address, because a single tenant with many users is exactly the shape that slips through a per-user limit. Give the limits a per-tenant configuration so an enterprise customer’s legitimate volume does not require raising the ceiling for everyone. And expose the tenant identifier in your metrics and traces so a latency investigation can answer “whose traffic is this?” in one query rather than through elimination.

Background work needs the same treatment. A queue shared by all tenants will happily let one customer’s ten thousand jobs delay another’s two, so either partition the queues or apply fair scheduling that interleaves work across tenants. The symptom of getting this wrong — “our reports were late on Tuesday” — is reported as a reliability problem and diagnosed as an isolation one.

Storage quotas complete the picture. Track usage per tenant, enforce a limit, and make the limit visible in the product before it is hit, so hitting it is a business conversation rather than an outage.

Answering the Isolation Questionnaire

Enterprise customers will ask how you keep their data separate, and the answer is far easier to give if the evidence is generated rather than written. Three artefacts cover most questions.

The first is a description of the model — shared database with row-level security, schema per tenant, or database per tenant — with the specific mechanism named. Vague claims of “logical isolation” invite follow-up questions; naming the enforcement point and showing the policy usually ends the thread.

The second is the test evidence: the two-tenant fixture, the pooled-connection concurrency test, and the scheduled invariant check, with their most recent results. Being able to say “this runs on every deploy and last passed an hour ago” is a stronger answer than any amount of architectural description.

The third is the access story for your own staff: who can reach customer data, through what mechanism, with what approval, and where the audit trail lives. That is the part customers most often care about and the part teams most often have not written down, because support access grows organically rather than by design.

A final note on regionality: some customers require their data to stay in a specific jurisdiction, and that requirement interacts with every choice above. Decide early whether region is a property of the tenant that routes them to a regional deployment, or a property of individual records within one deployment. The first is simpler to reason about and easier to prove; the second spreads the requirement across every table and every cache. Whichever you choose, record the region on the tenant so a request can be routed and audited against it, because retrofitting regionality into a system that assumed one location is among the most expensive migrations a SaaS product can face.

Whichever isolation model you adopt, revisit the decision when the tenant count changes by an order of magnitude. The trade-offs that make a shared database obviously right at fifty tenants look different at five thousand, and the operational cost of a database per tenant that seemed unthinkable at launch may be entirely reasonable once each customer is large enough to justify it. Treating the model as a decision with a review date, rather than a permanent architectural fact, is what keeps the migration optional rather than urgent.

Put that review on the same calendar as your capacity planning, so it happens while the options are still cheap rather than after a customer has forced the question during a procurement review.