"Multi-tenant" means one running app serving many separate customers.
Most do it the cheap way: one database, shared tables, and a
tenant_id column every query filters on.

req.tenantId comes from the API key or JWT on the server, never
from anything the client sends; otherwise none of this matters. The
query is fine. The guarantee isn't: isolation exists because a
person typed that WHERE, in every file, every time.
- Tenant A to Invoices API: auth token identifies tenant
- Tenant B to Invoices API
- Tenant C to Invoices API
- Invoices API to Postgres - shared tables, tenant_id column: tenant_id-filtered query
So a teammate ships a debug route, SELECT * FROM invoices WHERE status = 'overdue' LIMIT 20, and it returns the top 20 overdue
invoices from every tenant to whoever calls it. Nothing crashes.
The demo works. That's a data leak that looks like a feature.
Three ways to keep tenants apart
| Model | Isolation | Cost | Breaks when |
|---|---|---|---|
| Shared tables | logical: the query must filter | one schema, one pool | someone forgets the filter |
| Schema per tenant | a query without the schema fails | migrations × tenants | pooling and migrations at hundreds of schemas |
| Database per tenant | physical | a pool per tenant | a thousand tenants means a thousand pools |
Most teams start with shared tables because it's cheapest, then find the leak in production or an audit. The usual right move isn't to re-architect; it's to push enforcement into the shared database.
Make the database refuse
#postgres has row-level security: a policy attached to the table once, applied to every query from then on, whether the query filters or not.

The app sets app.tenant_id once per request, inside the transaction
that runs the queries:

Re-run the debug route: it still has no tenant_id clause, and it
now returns only the caller's rows. The forgotten clause went from a
leak to a non-event. The true in set_config scopes the setting to
the transaction, so a pooled connection can't carry tenant A's
setting into tenant B's request.
One more trap: a route that never calls withTenant. As written,
current_setting throws when the setting is missing, so the bug fails
loudly. Don't "fix" that error with a fallback:
Loud failure in the logs beats a tenant who suddenly appears to have no data.
Isolated rows, shared capacity
RLS decides which rows come back, not how much of the database a tenant may spend. One tenant's report scanning millions of rows still makes every other tenant wait: the noisy neighbour. The fixes are rate limits and connection pools, and both share one trap: they look right on one instance and are wrong on ten.
| Setting | Per instance (looks fine) | Across instances (reality) |
|---|---|---|
| Rate limit 100/min | 5 instances see ~20 each | the tenant actually gets 500/min |
| Pool of 20 connections | fine in staging | 10 instances open 200; Postgres allows 100 |
The rate-limit counter needs one shared home, so every instance counts against the same number. #redis is the usual pick:

Connections get the same treatment from the other side. Either divide Postgres's real budget by the instance count, or put a pooler in front that enforces the ceiling no matter how many instances exist:
- API instance to PgBouncer - connection pooler
- API instance to PgBouncer - connection pooler: many app-side connections
- API instance to PgBouncer - connection pooler
- PgBouncer - connection pooler to Postgres - fixed max_connections: few real connections, fixed regardless of instance count
PgBouncer's fast mode lends a real connection for one transaction at
a time. RLS survives that because its setting is transaction-scoped.
A schema-per-tenant design that switches search_path per session
does not: the next tenant to borrow the connection can inherit the
wrong schema.
Another tenant's invoice is a 404, not a 403
Tenant A asks for GET /invoices/:id with tenant B's ID. RLS returns
zero rows, so nothing leaks. But if the handler separately checks
"does this ID exist?" and answers 403 for "real, not yours" and
404 for "unknown", an attacker can enumerate every real ID on the
platform without reading a row: a textbook
broken object level authorization
bug.
Don't check existence separately. Zero rows is 404, whatever the
reason. Map every failure in one middleware, not per handler:
| Situation | Status | Why |
|---|---|---|
| No tenant resolved from auth | 401 | nobody is identified; stop before any query |
| Tenant-scoped query returns nothing | 404 | same answer whether missing or someone else's |
| Rate limit exceeded | 429 | with Retry-After |
| Pool exhausted | 503 | capacity, not a bug; Retry-After stops a retry storm |
app.tenant_id never set | 500 | a wiring bug that should page someone |
Business logic never sees a tenant id
The original leak was possible because ordinary code held an unscoped
database handle. So don't give it one. Pass a context whose db is
already inside withTenant:

markInvoicePaid has no tenant id to forget and no way to query
outside its tenant, and it can be unit tested with no auth or HTTP in
sight.
Convention or structure
Every failure in this post had the same shape: a guarantee held up by someone remembering, or by one instance assuming it was alone.
| Guarantee | By convention (fails quietly) | Structural |
|---|---|---|
| Rows stay with their tenant | WHERE tenant_id = ... everywhere | row-level security |
| A tenant's request budget | in-memory counter per instance | one shared counter |
| Database connections | pool size per instance | a pooler, or a divided budget |
| Existence of other tenants' data | a careful 403/404 check | zero rows is always 404 |
| Domain code stays scoped | passing tenantId around | a context with a pre-scoped db |
When a guarantee matters, ask what enforces it when somebody forgets. If the answer is "review", it isn't enforced yet.
Some tenants still need their own schema or database, usually for a
contract. That trades the forgotten WHERE for knowing where each
tenant lives and running every migration hundreds of times; see
Migrating Schema-Per-Tenant Databases at Scale.