Architecture patterns
Enforcing tenant scoping
How to make sure no query in a shared database returns another tenant's rows, using the tenant from login, automatic scoping, Postgres row-level security and leak tests.
In a pooled system, the most important rule is that no query returns another tenant's rows. Relying on every developer to remember the right WHERE clause, in every query, forever, will eventually fail. The answer is defense in depth: several layers that each catch what the others miss.
Concept
Row-level security (RLS) is a database feature that attaches a policy to a table, so the database filters rows for each request. It applies even to queries the application writes badly.
The big idea
Each layer checks something different:
- Derive the tenant from authentication. The tenant ID comes from the verified session or token. It never comes from a URL parameter or request body that a user can edit. An API that trusts
?tenant_id=is an insecure direct object reference waiting to happen. - Scope at the data-access layer. The repository or ORM adds the tenant filter automatically, so application code can't forget it. Many frameworks support default scopes keyed on a request-scoped tenant.
- Enforce in the database. Row-level security filters rows no matter what SQL the application sends.
- Test for leaks. Automated tests create two tenants, act as one, and assert that nothing from the other is visible through any API.
Walking through it
Postgres row-level security looks like this:
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY; -- apply even to the table owner
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);
-- Per request, inside the transaction, the app sets the tenant from the auth token:
BEGIN;
SET LOCAL app.tenant_id = '6f1c2a9e-0d4b-4c43-9a51-2f7e8b3d1c10';
SELECT id, tenant_id, status FROM invoices WHERE status = 'overdue';
COMMIT;The SELECT has no tenant filter, yet it returns only the rows for the tenant in app.tenant_id:
id | tenant_id | status
--------+--------------------------------------+---------
3018 | 6f1c2a9e-0d4b-4c43-9a51-2f7e8b3d1c10 | overdueUSING limits what can be read, updated or deleted. WITH CHECK blocks inserting a row into another tenant, or moving a row to one.
Details that bite:
- Superusers and roles with
BYPASSRLSignore policies. Table owners do too, unless you useFORCE ROW LEVEL SECURITY. Run the application as a dedicated role that owns nothing. - Use
SET LOCAL, notSET.SET LOCALlasts only for the transaction. With a connection pooler such as PgBouncer in transaction mode, a plainSETwould leak one request's tenant into the next request on the same connection. That is the exact bug RLS exists to prevent. - Lead every tenant-scoped index with
tenant_id. An index like(tenant_id, status)keeps the policy's filter fast. Without it, lookups become scans.
The same discipline applies beyond the database. Put the tenant ID in every cache key (t:{tenant}:user:{id}), every search document, every object-storage path and every message topic. A shared layer that ignores tenancy leaks what the database protects.
Common mistakes
- Taking the tenant ID from the request. An edited parameter then reads another customer's data.
- Using plain
SETbehind a pooler. The tenant setting outlives the request. - Running the app as the table owner or a superuser. The policies are silently skipped.
- Leaving cache keys unscoped. The cache then serves one tenant's data to another.
Check yourself
-
Why does
SET LOCALmatter when PgBouncer runs in transaction mode?A plain SET stays on the connection, so the next request reusing that connection would inherit the previous tenant.
-
Why is a tenant ID in a URL parameter risky?
A user can edit it to another tenant's ID. The server must check it against the authenticated session, or ignore it entirely.
Key takeaways
- Take the tenant from authentication, never from user input.
- Scope automatically in the data layer, so developers can't forget the filter.
- Enforce in the database with row-level security, and run the app as a non-owner role.
- Test for leaks with two tenants, and scope every cache key, search document and path.
Further learning
The PostgreSQL documentation explains row security policies, including the bypass rules that catch teams out.
PostgreSQL docs: Row security policies5.9. Row Security Policies # In addition to the SQL-standard privilege system available through GRANT, tables can have row security policies …PostgreSQL Documentation
Next: noisy neighbors, which covers what happens when one tenant uses too much.
Related
Multi-tenant SaaS architecture
Designing one system for many customers, covering isolation models, tenant scoping, noisy neighbors, tenant-aware sharding and per-tenant cost.
Authentication and authorization
Authentication proves who you are, authorization decides what you can do, and service-to-service calls need their own identity. The map for the rest of this topic.
Sharding and partitioning
Split data across machines by choosing a shard key, hashing or ranging it, routing requests, handling hot spots, resharding without downtime and generating IDs that stay unique.