Securing multi-tenant systems

Sep 13

Security Engineering / 01

A customer opens an invoice URL, changes the invoice ID, and sees another company’s bill. The request had a valid login. The SQL was parameterized. Neither control answered the question that mattered: is this person allowed to read this invoice?

Multi-tenant security starts with that distinction. Authentication establishes who is calling. Authorization decides what they may do within a tenant. Isolation keeps the decision intact as work moves through databases, caches, workers, storage, and administrative tools.

This guide develops the supplied OWASP multi-tenant security checklist into an implementation and review path. It is not a claim that one example makes an application secure.

On this page

Choose the boundary

Assume a tenant member can alter every client-controlled identifier, replay requests, and submit work faster than expected. Also account for ordinary bugs: a forgotten query filter, a stale permission cache, or a worker that inherits the previous job’s context.

Write down the allowed exceptions. A public catalogue, a deliberately shared document, and an authorized support operation are not isolation failures. They need explicit policies so that “shared” does not become a convenient explanation for any unscoped access.

Data layoutWhat can enforce isolationWhat still needs attention
Separate databasesCredentials, database access, network boundariesShared admin credentials, backup access, migrations and operating cost
Separate schemasDatabase roles and grants as well as namespacesSearch paths, role permissions and migration discipline
Shared tablesTenant ownership, constrained roles and row policiesEvery access path and every tenant-owned table
HybridA documented boundary for each data classInconsistent assumptions between workloads

A database per tenant does not isolate customers if every request uses credentials that can read every database. A schema name alone is not a permission boundary either.

Record which data is tenant-owned, intentionally global, user-private, or shared by policy. Include exports, audit records, search indexes and object versions—not just the tables behind your main API.

Verify tenant context

A tenant ID in a header is a selector. So is a subdomain, request parameter, or a field in a JSON payload. Check the authenticated caller’s current membership or explicit service authorization before accepting that selection.

The request path should look like this:

untrusted tenant selector + verified caller identity
                      |
              membership / scope check
                      |
              server-owned tenant context
                      |
          resource and operation authorization
                      |
               tenant-scoped data access

Reject a missing or invalid context on tenant-owned routes. Do not recover by issuing a query without a tenant filter. Public endpoints and genuinely global jobs can use their own explicit scope.

Pass verified context to downstream components that need it. If a service accepts a new header value and replaces the verified tenant, the boundary has moved back into the caller’s hands. Reset request-local state when the request ends.

A service identity may legitimately act across several tenants. Give it an explicit tenant set and operation scope. “Internal service” is not an authorization rule.

Keep database context inside the transaction

For shared-table PostgreSQL deployments, row-level security can provide another check when an application query forgets its tenant predicate. It works only under the roles and policies you actually deploy.

Here is a small policy example for a disposable database. Run the setup as an authorized administrator. The non-login role illustrates a constrained request role; configuring production login credentials, role membership, and connection pooling is separate work.

CREATE ROLE tenant_request NOLOGIN NOSUPERUSER NOBYPASSRLS;

CREATE TABLE public.tenant_notes (
  tenant_id uuid NOT NULL,
  note_id uuid NOT NULL,
  body text NOT NULL,
  PRIMARY KEY (tenant_id, note_id)
);

ALTER TABLE public.tenant_notes ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.tenant_notes FORCE ROW LEVEL SECURITY;

CREATE POLICY tenant_notes_scope
ON public.tenant_notes
TO tenant_request
USING (
  tenant_id = current_setting('app.current_tenant')::uuid
)
WITH CHECK (
  tenant_id = current_setting('app.current_tenant')::uuid
);

GRANT USAGE ON SCHEMA public TO tenant_request;
GRANT SELECT, INSERT, UPDATE, DELETE
ON public.tenant_notes TO tenant_request;

The read predicate limits visible rows. The write check constrains the tenant identity of inserted and updated rows. This is one table, not policy coverage for the rest of an application.

Set the verified tenant for each transaction on the same checked-out connection that runs the queries. This demonstration uses a literal tenant UUID; application code should bind a server-verified value as a parameter.

BEGIN;
SET LOCAL ROLE tenant_request;

SELECT set_config(
  'app.current_tenant',
  '11111111-1111-4111-8111-111111111111',
  true
);

SELECT note_id, body FROM public.tenant_notes;
COMMIT;

The final argument to set_config makes the setting transaction-local. Re-establish it for the next transaction and commit or roll back before releasing the connection. A session-level setting can survive a committed transaction and leak into a later pooled request. PostgreSQL documents both setting scopes.

This policy uses current_setting without the missing-value option. An absent setting raises an error; a malformed or empty UUID also fails rather than selecting all tenants. Do not catch those failures and retry an unscoped query.

There are two important limits:

  • Superusers and roles with BYPASSRLS bypass row security. FORCE ROW LEVEL SECURITY subjects a table owner to policies; it does not constrain those privileged roles. Check rolsuper and rolbypassrls in pg_roles for the deployed request identity. See PostgreSQL’s row-security rules.
  • A custom tenant setting is not proof of membership. This example trusts the application to verify and set it. Arbitrary SQL execution through that same role can undermine a setting-based policy. Keep parameterized SQL and application authorization, and do not advertise this as isolation from a compromised application.

Keep privileged migrations and cross-tenant administration off the normal request path. Review other grants and policies too: a permissive policy added later can broaden access. Tenant-owned relationships also need constraints that prevent a row from pointing into another tenant.

An ORM filter is useful defense in depth, but raw SQL, bulk operations, alternate sessions and Core connections may not traverse it. Test those paths rather than assuming every query goes through your helper.

Authorize objects and caches

Resolve tenant-owned resources through the verified scope. An invoice lookup should establish both the invoice identity and the tenant in which the caller may perform the requested action. Random IDs make guessing harder; a leaked random ID still needs authorization.

Caches need the same care. Classify the value before choosing the key:

global:currency-codes:v1
tenant:<verified-tenant>:invoice:<invoice-id>:v3
tenant:<verified-tenant>:user:<user-id>:permissions:<version>

These are naming examples, not a complete key design. Include every dimension that changes the result: permissions, user, locale or feature set where applicable. Authorize access before serving a protected cache hit. A well-scoped key cannot make stale membership valid.

Choose expiry and invalidation from the freshness and permission contract. Not every immutable global entry needs a TTL, but cached authorization must have a deliberate revocation policy. The cache-isolation essay goes deeper into these choices.

Carry the boundary through workers

An authenticated HTTP request may enqueue a job that runs after the user loses access. Decide which permissions must still hold at execution time.

For tenant-owned jobs, bind tenant context to an authenticated producer path, trusted routing, or integrity-protected metadata. At consumption, verify the path, establish context again, and authorize the target operation. A tenant_id inside an untrusted message proves nothing.

Scope deduplication keys, retry records, dead-letter access and ordering state whenever their effects vary by tenant. Reusing another tenant’s idempotency key must not return their response or suppress their work.

Classify global and authorized cross-tenant jobs explicitly. A fabricated tenant ID does not make a platform maintenance job safe. If jobs are delayed, test revocation during the delay. See Kafka quarantine and replay boundaries.

Protect files and shared capacity

For a tenant-owned object, authorize the exact object and operation before issuing a signed URL. Pick an object key, bucket, account or storage policy that enforces the intended boundary. A tenant prefix is useful organization, but without enforcement it remains a naming convention.

Signed URLs are capabilities. Limit the operation and validity window to the task. Do not assume that removing application membership immediately invalidates a URL already issued. The tenant name need not appear in that URL if authorization occurred before signing.

Where the threat or compliance model requires it, use tenant-specific encryption keys. Separate keys do not replace object-access checks. For upload validation and overwrite races, use the S3 finalization guide.

Availability also belongs in the isolation review. A tenant can remain within an HTTP request-rate limit while creating expensive fan-out, a deep queue, or too many concurrent queries.

Apply tenant-aware budgets at the actual bottleneck: work in flight, queued jobs, connections, CPU or memory. Retain global caps so many individually compliant tenants cannot exhaust the fleet. Dedicated worker pools can reduce the affected population, at the cost of capacity and operations. Choose limits from measured workloads and commitments—not copied constants.

Close access during offboarding

Deleting a tenant row is not a deletion policy. Inventory active stores, caches, object versions, replicas, search copies, exports and backups. Record what is deleted immediately, what expires later, and what must be retained under a documented legal or contractual basis.

Revoke tenant credentials and access, stop new work, and handle queued jobs before declaring offboarding complete. A restored backup must not silently restore access that was revoked after the backup was taken.

Keep provisioning and deletion evidence in an access-controlled audit trail. Tenant-scoped audit events should carry server-verified tenant context. Restrict both tenant reads and platform-wide investigations; centralized logging does not justify unrestricted access. Do not log credentials, signed URLs or sensitive payloads just to make debugging easier.

High-entropy generated API tokens and user-chosen passwords need different storage treatment. Do not generalize a fast-digest pattern for random tokens into a password-storage design.

Release checklist

Use two tenants, two ordinary users, and an explicitly privileged identity. Exercise the deployed request role, network path and pooling mode—not just an administrator’s test connection.

TestEvidence to keep
Substitute another tenant’s object ID in read, update, delete and export pathsDenied access without another tenant’s data or effects
Omit context or supply a malformed valueFailure without an unscoped fallback
Reuse a database connection across tenants, including rollback pathsNo inherited context or cross-tenant rows
Attempt to insert or move a row into another tenantRejected write under the request role
Add a tenant-owned tableClassification, policy and negative tests are required
Read a cache entry with another tenant’s scope or stale permissionsNo unauthorized cache hit
Tamper with a job’s tenant, then replay itRejection or authorized handling, with scoped deduplication
Revoke membership before a delayed job or cached answer is servedThe documented revocation rule is enforced
Saturate one tenant’s queue or expensive endpointOther tenants retain the promised service budget
Offboard, restore a backup, and retry old access pathsRetention and revocation rules still hold

Discover table coverage from the schema or enforce classification during migrations. A manually maintained list can omit the very table whose missing policy you need to catch. Compare classified tables with pg_class.relrowsecurity, relforcerowsecurity, and pg_policies.

Also test the successful paths. A system that denies every request has not demonstrated correct authorization. Intentional sharing and support access need positive tests that prove exactly what is permitted, alongside negative tests for everything else.

When a boundary fails

For a suspected cross-tenant disclosure, contain the affected path and preserve access-controlled evidence. Investigate whether the same flaw exists in exports, cache hits, jobs and administrative tools. A repaired endpoint is not proof that every copy or access path is repaired.

Assign an owner to each isolation control and its test. The useful outcome of this guide is a review someone can rerun after a schema change, new worker, permission feature or storage migration.

Sources and attribution

Adapted and expanded from the OWASP Multi-Tenant Application Security Cheat Sheet, contributed by the OWASP Cheat Sheet Series community. Changes include the walkthrough, tenant-notes SQL example, cross-layer scenarios and release-test matrix. This adapted guide is shared under CC BY-SA 4.0; it does not imply OWASP endorsement.

Back to Security Engineering

>