Almost every SaaS starts single-tenant by accident. The first customer is the only customer, so a query for leads is just a query for leads. The second customer arrives and the same query now returns both companies' data.
It is a data-model problem, not an auth problem
The instinct is to fix it in the permission layer: check the user's role, check they are allowed. That does not close the hole. A user with legitimate `lead.read` who asks for another workspace's lead ID passes authentication, passes the role check, and passes the UI gate. Only a workspace filter on the query stops it.
The tenant ID must never come from the client. It comes from the verified session, server-side. A request that supplies its own workspace ID is telling you which data it would like to read.
Why retrofitting is expensive
Adding tenant scoping later is not a migration, it is an audit. Every query in the codebase has to be found, read, and classified as either correctly scoped or deliberately cross-tenant. Miss one and you have a data leak that no test catches, because the test suite has one tenant in it.
Some queries genuinely are cross-tenant — platform admin views, billing reconciliation, usage reporting. That is exactly what makes the audit hard: you cannot simply assert that every query has a filter, so a reviewer has to judge each one.
Make the rule enforceable
A convention nobody can check is a convention that decays. The version that holds is a lint rule: every query in your service and repository layers either carries a workspace scope or an explicit exemption comment saying why it does not.
The exemption is the important half. It turns cross-tenant access from something that happens quietly into a list you can enumerate and review. And once the rule is an error rather than a warning, the next unscoped query fails the build instead of joining a backlog nobody reads.
What to get right at the start
- A workspace or tenant ID on every row that belongs to a customer
- The tenant resolved from the session on the server, never from a request parameter
- A single scoped query helper, so the filter is hard to forget
- A lint rule with explicit, reviewable exemptions for genuine cross-tenant reads
- At least two tenants in the test fixtures, so a leak fails a test
None of this is expensive on day one. It is a schema column, a helper, and a rule. It is expensive on the day an enterprise prospect asks how you guarantee isolation and the honest answer is that you check it by reading.