Security

Multi tenant isolation when the data is someone's inbox

Aug 25, 2026 · 3 min read

In most multi-tenant products, an isolation bug shows one customer another customer's records. In a communications platform, it shows one customer another customer's email. That difference in consequence is large enough that the answer has to live in the architecture, not in a code review checklist.

Build the auth context once, at the boundary

Authenticate at the edge, in one place. The request arrives with an API key; you resolve it to an organization, an application, a set of scopes, and a request identifier, and you put those in a single immutable context object. Everything downstream receives that context as an explicit argument. No globals, no ambient thread-local state, no re-deriving the tenant from a header three layers deep.

The payoff is that isolation becomes checkable. There is exactly one function that builds the context, so there is exactly one place to audit. A request that never passed through it never reaches business logic, because nothing downstream can even be called without the context in hand.

Every query carries the tenant

The repository layer should refuse to work without a tenant. Every method takes the context, and every query it produces includes the organization and application identifiers in its filter. Not most queries. Every query, including fetch-by-id, because an identifier leaked into the wrong hands must behave exactly like an identifier that does not exist. "Found, but belongs to someone else" and "not found" must be the same response.

Make the unscoped query the hard thing to write. If a developer has to go out of their way, invent a new function, and get it past review to touch rows without a tenant filter, your architecture is doing the work your checklist used to do.

Why "we filter in the service layer" fails

Filtering in the service layer is a discipline claim dressed up as a design. It holds only if every developer remembers the filter, on every code path, forever. The paths that forget are predictable: the new endpoint written under deadline, the background job that runs without a request, the admin script that talks to the database directly, the report query someone writes on a Friday. None of them pass through the careful code.

One missing filter is not a degraded state you notice on a graph. It is rows crossing tenants silently until someone's customer sees a stranger's subject lines. Structure survives turnover and deadlines. Discipline does not.

Isolation extends past the database

The database is the obvious surface, and not the only one. Cache keys need the tenant in them, or one organization's data gets served from another's warm cache. Queue payloads should carry the tenant and be re-verified at consumption, because a job enqueued under one context must not execute under another. Webhook deliveries need the same care: an endpoint registered by one application must never receive another application's events. And logs should identify resources without quoting their contents; a message body in a log line is an isolation failure, whatever the query layer did right.

Audit logs close the loop

Scoped queries prevent most cross-tenant access. Audit logs are how you prove what actually happened. Record each access to customer data with the organization, application, actor, resource, and request identifier, in append-only form. When a customer asks who read a mailbox, you answer with records. When you ship an isolation bug anyway, the log turns "we believe impact was limited" into a measured list of affected requests.

This is how Horato is built: every API call is authenticated and scoped to an organization and application, with the context constructed once at the boundary and passed through every layer beneath it. The pattern is not novel. What matters is refusing every shortcut around it, because the data behind the API is someone's inbox.