Skip to main content
LinkGro

Multi-tenancy before your second customer

Tenant isolation is a data-model decision, not a permissions one. Why retrofitting it means touching every query, and the guardrail that makes it stick.

6 min read

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.

Working on something this touches?

If one of these matched a problem you are having, the conversation is usually shorter than the article.