NubliVaultby Nublify
← Blog

· 8 min read

Multi-tenant: how to stop one customer's backup from leaking into another's

Application-layer validation fails on the path nobody reviewed. The isolation that survives a distracted team lives in the database, not in the code.

Every multi-tenant backup system has the same failure point: a query without WHERE tenant_id = ?. It isn't a sophisticated attack. It's a new endpoint written on a Friday, a management report, a cleanup job, a query run by hand to answer a support ticket.

The damage is disproportionate to the mistake. In a backup system, "leaking a record" rarely means leaking a record. It means exposing another company's directory tree, its file names, its working hours, the size of its ERP database — and, in the worst case, giving one customer the ability to restore another's content.

Why validating in the application isn't enough

Application-layer checks work. The problem is that they work exactly in the places where someone remembered to write them.

The paths that slip through are always the same:

  • New endpoints. The team copies an existing controller, swaps the entity and forgets the filter. Code review catches most of them. "Most" is not a security control.
  • Async jobs. Expiration workers, usage recalculation, metrics collection. They run without an authenticated user, so the developer has no tenant context at hand and writes the global query.
  • Reports and exports. Almost always raw SQL, almost always outside the ORM, almost always outside the layer that enforces the rule.
  • Migrations and fix-up scripts. Someone has to repair 4,000 rows at two in the morning. The script doesn't go through the application.
  • Loading by ID. GET /snapshots/8142 loads the snapshot and returns it. If the ID is global and sequential, enumeration is trivial.
  • Support. The internal tool that can read any tenant is legitimate — until its credential gets reused by a service that shouldn't have it.

The rule of thumb: if isolation depends on someone remembering, it will fail in proportion to team size multiplied by time. Isolation has to be the default condition, not a conscious act.

The leak surface of a backup system

Worth listing, because it's larger than it looks:

  1. The index. Full path, name, size, modification date, hash. It's a map of the company. It leaks on its own, without a single byte of content.
  2. The content blocks. The data itself.
  3. The keys. If there's one key per tenant, controlling who can read it is as critical as controlling the data — a topic covered in who can open your backup.
  4. Restore. The least-exercised path in the system, and the one that hands content straight to someone. We discussed it in why restore is the part nobody tests.
  5. Logs, metrics and telemetry. A file path in an error message is a leak with long retention and copies in three observability systems.
  6. Global deduplication. This one deserves its own section.

Cross-tenant deduplication leaks by construction

Content-hash deduplication shared across customers saves storage and creates an existence oracle. Anyone who can observe that an upload resolved to "already exists" learns that another customer of the same system has that exact file. For a public file, harmless. For a contract in PDF, a payroll spreadsheet or an internal binary, not harmless.

The mitigation is tedious and well known: per-tenant deduplication scope, or a distinct derivation key per tenant before hashing. You pay in space what you save in risk. It's an explicit decision, not an implementation detail.

The isolation that survives: constraints in the database

The idea is to invert the default. Instead of the query having to ask for the filter, it has to ask for permission to go without one.

1. tenant_id on every data table, NOT NULL. No exceptions "because this table is metadata". Backup metadata is customer data.

2. Composite primary key: (tenant_id, id). And every foreign key carries the tenant_id along:

PRIMARY KEY (tenant_id, id),
FOREIGN KEY (tenant_id, snapshot_id)
  REFERENCES snapshots (tenant_id, id)

This is what prevents, at the database level, a record belonging to one tenant from pointing at another tenant's parent. It's the class of bug that code review struggles to catch, because the code looks correct.

3. Row Level Security enabled and forced. In PostgreSQL, the policy lives on the table and applies to any access path — ORM, raw SQL, background job, the on-call engineer's psql session. The official documentation describes the behaviour in Row Security Policies.

Four details decide whether the protection is real:

  • ALTER TABLE ... FORCE ROW LEVEL SECURITY. Without FORCE, the table owner is not subject to the policy — and the application usually connects as the owner.
  • The application role must not have BYPASSRLS and must not be a superuser.
  • Writes must be covered too, not only reads. In a FOR ALL or FOR UPDATE policy without WITH CHECK, PostgreSQL uses the USING expression itself to reject a new row with someone else's tenant_id; a FOR INSERT policy only accepts WITH CHECK. Declaring WITH CHECK explicitly doesn't change the effect and makes the intent readable.
  • The tenant comes from a session parameter, e.g. current_setting('app.tenant_id'), set at the start of every transaction with SET LOCAL. With a connection pool in transaction mode, SET without LOCAL leaks the context into the next transaction, which may belong to a different customer. This is the most dangerous misconfiguration of the lot, because the system works perfectly in development, where there is no pool.

4. Opaque IDs on the outside. UUIDs or random identifiers in the API. It doesn't solve authorization — it just takes trivial enumeration off the table.

5. The same principle in object storage. A per-tenant prefix, derived from the authenticated context on the server and never from a field sent by the client. Where the provider allows it, temporary credentials scoped to the prefix, so the scope is enforced outside your process. Pre-signed URLs generated per tenant, with short lifetimes.

How to test: write the test that tries to leak

A test confirming "customer A sees A's data" passes even with RLS turned off. The test that matters is the opposite one.

  • For each endpoint, a call authenticated as tenant B requesting one of A's IDs. Expected response: 404, not 403 — a 403 confirms the resource exists.
  • A database test that opens a connection with the application role, sets app.tenant_id to B and attempts SELECT, UPDATE and DELETE on A's rows.
  • A schema check in CI that fails if any data table exists without tenant_id or without an active policy. This covers the table that will be created six months from now by someone who never read this document.
  • A full restore test with two populated tenants, verifying the count and the hashes of the delivered content.

What this article does not prove

This approach has costs and limits, and omitting them would be dishonest.

RLS affects the planner. The policy condition enters every query. On large tables, this changes plans and requires indexes to start with tenant_id. Measuring at your own volume is mandatory; there is no generic number here.

Composite keys cost more. Larger indexes, more verbose joins, ORMs that complain. It's real refactoring work in a system that's already in production.

Not every database offers this. In databases without RLS, the closest equivalents are one user per tenant, one schema per tenant, or restricted views — each with its own list of pitfalls. The principle holds; the implementation does not transfer.

One database per tenant isolates better. It's the strongest option and the most expensive: more connections, more migrations, more monitoring, more work on every deploy. For a handful of large customers it usually pays off. For many small customers, rarely.

Database isolation does not cover everything. It does not protect against a compromised administrator credential, a bug in the storage provider, a key shared across tenants, or an in-memory cache keyed without the tenant. Cache is a frequent blind spot: the database policy cannot see Redis.

None of this replaces retention and immutability. Isolation prevents one customer from reading another's backup. It does not prevent the backup from being deleted — that's a different control, described in immutable backup with Object Lock.

The summary is discouraging and true: the effective part of the solution is bureaucratic. Constraints, policies, negative tests, schema checks in CI. There's nothing elegant about it. What there is, is a system where the wrong query fails instead of returning another company's data.


NubliVault is Nublify's backup product: it packs millions of small files and stores them in a cloud separate from the source. How it works · Security