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
- Verify tenant context
- Keep database context inside the transaction
- Authorize objects and caches
- Carry the boundary through workers
- Protect files and shared capacity
- Close access during offboarding
- Release checklist
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 layout | What can enforce isolation | What still needs attention |
|---|---|---|
| Separate databases | Credentials, database access, network boundaries | Shared admin credentials, backup access, migrations and operating cost |
| Separate schemas | Database roles and grants as well as namespaces | Search paths, role permissions and migration discipline |
| Shared tables | Tenant ownership, constrained roles and row policies | Every access path and every tenant-owned table |
| Hybrid | A documented boundary for each data class | Inconsistent 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 accessReject 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
BYPASSRLSbypass row security.FORCE ROW LEVEL SECURITYsubjects a table owner to policies; it does not constrain those privileged roles. Checkrolsuperandrolbypassrlsinpg_rolesfor 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.
| Test | Evidence to keep |
|---|---|
| Substitute another tenant’s object ID in read, update, delete and export paths | Denied access without another tenant’s data or effects |
| Omit context or supply a malformed value | Failure without an unscoped fallback |
| Reuse a database connection across tenants, including rollback paths | No inherited context or cross-tenant rows |
| Attempt to insert or move a row into another tenant | Rejected write under the request role |
| Add a tenant-owned table | Classification, policy and negative tests are required |
| Read a cache entry with another tenant’s scope or stale permissions | No unauthorized cache hit |
| Tamper with a job’s tenant, then replay it | Rejection or authorized handling, with scoped deduplication |
| Revoke membership before a delayed job or cached answer is served | The documented revocation rule is enforced |
| Saturate one tenant’s queue or expensive endpoint | Other tenants retain the promised service budget |
| Offboard, restore a backup, and retry old access paths | Retention 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.