Multi-Tenant Apps Live or Die on Scoping

In a multi-tenant system, one missing WHERE clause is a data breach. Where tenant isolation actually breaks — jobs, exports, admin paths — and how to build scoping you can trust.

org Aorg BWHERE org_id = ?
Rick
Senior Security Engineer
Jan 28, 2026
6 min read

The scariest bug report I've ever triaged was three sentences long: "I logged in this morning and the dashboard showed projects that aren't ours. Company names I recognize. Screenshot attached."

That's a cross-tenant data leak, and in a B2B product it's about the worst thing short of losing the database outright. Your customers gave you their data on the promise that other customers can't see it. Every one of those promises hangs on the same slender thread: that every single query, in every code path, remembers the tenant clause. Miss it once, anywhere, and Customer A is reading Customer B's data.

I've come to think of tenant isolation not as a feature but as an invariant — something that must hold everywhere, always, or it doesn't hold at all. And invariants enforced by human memory fail. Let me show you where they fail, because the failures cluster in very predictable places.

The request path is the easy part

Most teams get the obvious case right. A request comes in, middleware resolves the tenant from the subdomain or the session, controllers query through it:

# resolved once per request
current_tenant = Tenant.find_by!(subdomain: request.subdomain)

# every query goes through the association
current_tenant.projects.find(params[:id])

If your codebase does this consistently, the interactive web path is in decent shape. The leaks almost never come from there. They come from everywhere that isn't a normal web request — the places where "current tenant" stops being an obvious ambient fact.

Where isolation actually breaks

Background jobs

A job runs with no request, no session, no subdomain. Whatever tenant context it has is whatever you explicitly passed it. The classic failure: a job takes a bare record ID.

class ExportJob
  def perform(report_id)
    report = Report.find(report_id)  # which tenant? nobody knows
    ReportMailer.send_export(report)
  end
end

Nothing here checks that the report belongs to the tenant that enqueued the job. On the happy path it doesn't matter — the ID came from a scoped controller. But job arguments live in a queue, get retried, get manually re-enqueued from consoles during incidents, and occasionally get constructed by other jobs. The moment an ID crosses a tenant boundary anywhere upstream, this job cheerfully mails one tenant's report to another. My rule: jobs take a tenant ID as an explicit argument and re-scope every lookup through it. The job re-proves tenancy; it never inherits trust from the enqueuer.

Exports, reports, and anything "batch"

Export code is written under deadline, touches many tables, and often gets built with raw SQL for performance. Raw SQL means hand-written WHERE clauses, and a JOIN that forgets AND comments.tenant_id = ? on one table out of six can smuggle cross-tenant rows into a CSV that gets emailed out automatically. Exports deserve more review scrutiny than interactive endpoints, not less, because their output leaves your system and can't be un-sent.

Admin and support tooling

Internal admin panels legitimately cross tenants — that's their job. The danger is when admin code paths share helpers with customer-facing code and the "skip tenant scoping" switch leaks. If your codebase has an unscoped escape hatch or a Tenant.without_scope { } block, treat every use of it like a sudo: it should be rare, greppable, justified in a comment, and never reachable from a customer-facing controller. In review, a new call to the escape hatch is automatically a conversation.

Caches and search indexes

The database isn't the only place data lives. A cache key like "dashboard_stats_#{user.id}" is fine until users can belong to multiple tenants; then it needs the tenant in the key. Search indexes are worse — if documents get indexed without a tenant field, or queries forget the tenant filter, your search endpoint becomes a cross-tenant reading room. Every data store you add re-opens the isolation question from scratch.

Building isolation you don't have to remember

The theme in all of these: isolation fails wherever it depends on a developer remembering. So the goal is to move the invariant out of memory and into structure.

Default scopes at the data layer. Whether it's Rails' acts_as_tenant-style gems, a repository layer that requires a tenant handle to construct, or middleware that sets tenant context all queries inherit — make the scoped query the path of least resistance and the unscoped query an explicit, ugly, greppable exception.

Database-level enforcement where you can get it. Postgres row-level security is the strongest version: the database itself refuses to return rows for the wrong tenant, even when application code has a bug. It has real operational costs and isn't right for every team, but it changes the failure mode from "breach" to "empty result," which is a trade I'll take. A cheaper middle ground: composite foreign keys or check constraints that make cross-tenant references unrepresentable.

Tests that attack the boundary. For every new surface — endpoint, job, export — I want a test where tenant A explicitly requests tenant B's data and gets nothing. And once a quarter, it's worth running a query in production analytics: are there any rows whose foreign keys cross tenant lines? Finding zero is cheap reassurance. Finding three is a quiet Tuesday incident instead of a customer screenshot.

Reviewing generated code through this lens

Multi-tenancy is exactly the kind of context AI assistants don't have. Your tenancy rules live in your architecture docs and your team's heads — not in the diff the assistant saw. So generated code arrives tenant-blind: Report.find(id) in a new job, a cache key without the tenant, a search query without the filter. It's not that the code is wrong in general; it's wrong in your house. Reviewers are the ones holding the house rules. When a generated diff touches data access, my first pass ignores the logic entirely and just traces the tenancy: where does tenant context enter, and does every query downstream carry it?

My isolation review checklist

  • Does every query in this diff go through the tenant scope — including in jobs, exports, and rake tasks?
  • Do background jobs receive tenant context explicitly and re-scope lookups, rather than trusting bare IDs?
  • Any new use of the unscoped escape hatch? Is it justified and unreachable from customer paths?
  • Do cache keys and search documents include the tenant?
  • Is there a test where the wrong tenant asks for this data and gets nothing?

One missing clause, anywhere, is the whole ballgame. Build the system so the clause is impossible to forget, and review like the one place it was forgotten is in the diff in front of you. Statistically, one day it will be.

multi-tenancytenant-isolationdata-leaksscopingbackground-jobs
Written by
Rick
Senior Security Engineer · Firetrail review team
More Security