The one mistake that matters most
In a multi-tenant SaaS API, the worst possible bug isn't a malformed response — it's a *correctly formed* JSON response that contains another customer's data. Every design decision below exists to make that class of bug structurally harder to write, not just to organize the JSON nicely.
Where the tenant ID actually lives
Three common places, each with a different enforcement story:
1. In the URL path: GET /tenants/acme-corp/orders/42
2. In a header: GET /orders/42 + X-Tenant-ID: acme-corp
3. Implicit from auth: GET /orders/42 (tenant derived from the API key/JWT, never sent explicitly)Path-based makes tenant scoping visible in every log line and every URL, but requires every single route to remember to filter by the path's tenant ID — miss it in one query and you have a cross-tenant leak that's easy to overlook in code review, since the buggy query still looks superficially correct.
Header-based keeps URLs tenant-agnostic (useful if the same order ID format is reused per-tenant) but means tenant scoping is invisible unless someone thinks to check request headers.
Implicit-from-auth is the pattern most production SaaS APIs converge on: the tenant ID is *never* client-supplied at all — it's derived server-side from the authenticated API key or JWT claim, so there's no request field a client could tamper with to access another tenant's data even if application logic elsewhere had a bug. This turns "did we forget to filter by tenant" into a single enforcement point (the auth middleware) instead of a per-endpoint responsibility.
A response shape that makes scoping visible
{
"data": {
"id": "ord_9182",
"tenant_id": "acme-corp",
"total": 129.99
},
"meta": {
"tenant_id": "acme-corp"
}
}Echoing the tenant ID back in every response — even though the client already knows which tenant it authenticated as — gives you a cheap, automatable safety net: an integration test that asserts response.meta.tenant_id === expectedTenant on every endpoint catches a cross-tenant leak immediately, rather than relying on a human noticing unfamiliar data in a support ticket months later.
Database-level enforcement, reflected in the API layer
The API response shape is the last line of defense, not the first — the query that produced it should already be scoped. Two common patterns:
-- Application-level filtering (every query must remember this WHERE clause)
SELECT * FROM orders WHERE tenant_id = $1 AND id = $2;
-- Row-level security (Postgres RLS) — enforced by the database itself,
-- so a forgotten WHERE clause can't leak data even if application code has a bug
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.current_tenant')::uuid);Row-level security pushes the enforcement down to the database, which is meaningfully more defense-in-depth than trusting every ORM query and hand-written SQL statement across a codebase to remember the same WHERE tenant_id = ? clause — a JSON API response is only ever as isolated as the query that produced it.
Handling shared/global resources
Not everything in a multi-tenant system belongs to exactly one tenant — a plan catalog, a public template library, system-wide feature flags. Model these explicitly rather than giving them a null or placeholder tenant ID, which is easy to misread as "belongs to no one" when it actually means "visible to everyone":
{ "id": "plan_pro", "tenant_id": null, "scope": "global" }An explicit scope field ("tenant" vs. "global") documents the intent directly in the response, rather than leaving a reader to infer meaning from an absent field.