LIANA GRIGORY

Home / Writing

Per-tenant isolation in Firestore, enforced where it cannot be bypassed

Every multi-tenant product eventually answers one question: what stops customer A from reading customer B's data? The comfortable answer is that the code never asks for it. That answer is worth nothing. The client is a program running on a stranger's computer, and anyone who can open developer tools can change what it asks for.

So the only interesting place to put tenant isolation is the layer that cannot be edited by the person you are defending against. In Firestore, that is Security Rules.

Start from deny

The first rule in the file should refuse everything, and every allowance after it should be a deliberate exception you can defend out loud:

rules_version = '2';
service cloud.firestore {
  match /databases/{db}/documents {

    // Nothing is readable or writable unless a rule below says so.
    match /{document=**} { allow read, write: if false; }

A surprising number of production rule files never do this, because the default project template hands you a time-boxed allow read, write: if request.time < ... and people extend it instead of replacing it. Deleting that line is the single highest-value edit in most Firebase projects.

Put the tenant in the token, not the document

The instinct is to store a tenantId on the user document and have rules look it up. That works, and it costs a document read on every single rule evaluation, billed, forever. It also creates a bootstrapping problem: reading the user document to decide whether you may read the user document.

The better place is a custom claim on the auth token, set only by trusted server code:

// Server side, in a trusted context only. Never callable by a client.
await admin.auth().setCustomUserClaims(uid, { tenantId: 'agency_7f31', role: 'manager' });

Now the tenant travels with every request, signed, and rules can read it for free:

function tenant()   { return request.auth.token.tenantId; }
function signedIn() { return request.auth != null; }

match /agencies/{agencyId}/clients/{clientId} {
  allow read: if signedIn() && agencyId == tenant();
  allow create, update: if signedIn() && agencyId == tenant()
                        && request.auth.token.role in ['owner','manager'];
  allow delete: if false;
}

Three properties follow from this shape and all three matter. The tenant boundary is a path segment, so it is impossible to write a query that crosses it. The role check reads a signed claim, so a client that edits its own local state changes nothing. And deletion is simply not exposed, because in this product records are archived rather than destroyed.

Server-owned fields must be immutable from the client

Isolation is not only about which documents you can reach. It is also about which fields you can move. A caregiver who can write to their own record can promote themselves to owner unless you stop them:

match /agencies/{agencyId}/staff/{uid} {
  allow update: if signedIn() && agencyId == tenant() && uid == request.auth.uid
    // the person may edit their own profile, but not their standing in it
    && !request.resource.data.diff(resource.data)
         .affectedKeys()
         .hasAny(['role','status','agencyId','payRate','createdAt']);
}

diff().affectedKeys() is the workhorse of safe rules and it is under-used. It lets you say precisely what a writer is allowed to touch, which is a far stronger statement than validating the values they sent.

The trap that costs a morning

A read rule written against resource.data denies when the document does not exist, because there is no resource. So a client that does a pre-write getDoc() to check for an existing record will be refused on every first-time create, and the failure looks like a permissions bug rather than a missing document. Gate reads on the path and the token wherever you can, and reserve resource.data for update rules where the document is guaranteed to exist.

The related one: in Storage rules, an overwrite is a create, not an update. If you meant "upload once, never replace", you need resource == null in the create rule, or your write-once bucket quietly is not.

Prove it, then say it works

Rules that have not been tested against a denial are decoration. The Firebase Rules API will evaluate a rule set against a synthetic request without touching production data and without creating any accounts, which means the negative cases — the ones you actually care about — are cheap to assert:

POST https://firebaserules.googleapis.com/v1/projects/PROJECT:test
{ "source": { "files": [{ "name": "firestore.rules", "content": "..." }] },
  "testSuite": { "testCases": [{
      "expectation": "DENY",
      "request": { "auth": { "uid": "u1", "token": { "tenantId": "agency_A" } },
                   "path": "/databases/(default)/documents/agencies/agency_B/clients/c1",
                   "method": "get" } }]}}

One assertion per boundary you claim to hold. If a rule has never returned DENY in a test, you do not know that it can.

Security that depends on the interface behaving is not security. It is an arrangement that has not been attacked yet.

Written by Liana Grigory, also written Liana Grigoryan — entrepreneur, technology founder and U.S. Army veteran in Los Angeles. More at Writing.