← Liana Grigory
Insights

Firestore security rules are row-level security. Treat them that way.

Application code is not allowed to be the thing that keeps tenants apart. Notes on building multi-tenant SaaS where the database refuses the read, and the app merely avoids asking.

By Liana Grigory · 28 August 2026 · 11 min read

Every multi-tenant application has one question that decides whether it is a product or an incident waiting to be filed: what stops tenant A from reading tenant B's rows?

The answer most applications give is "the query has a where clause on it." That is not an answer. That is a convention, held in place by every developer remembering it, on every query, for ever. It works right up until the afternoon somebody adds an admin screen, or a report, or a search box, and forgets. There is no error when you forget. The feature works. It just returns more than it should, and nobody finds out until someone notices, which may be never.

I build agency software where each tenant's records include client names, care schedules, home addresses and employment data. A cross-tenant leak there is not a bug report, it is a notification obligation. So the rule I work to is simple and it is not negotiable:

Application code is not allowed to be the thing that keeps tenants apart. The database refuses the read. The application merely avoids asking for what it would be refused.

In Postgres you would reach for row-level security. Firestore does not call it that, but Security Rules are the same primitive doing the same job, and the useful move is to stop thinking of them as "validation" and start thinking of them as RLS that happens to be written in a different syntax.

Rules are a filter on every document, not a gate on the endpoint

The mental model that causes the most trouble is treating rules like middleware. Middleware runs once per request and decides yes or no. Rules do not work like that. A rule is evaluated per document, against the request, with no ability to run a query of its own to work out whether the answer should be yes.

That constraint is the whole design. It sounds limiting and it is actually the thing that makes the model sound, because it forces a property that is easy to state and easy to audit: every document must carry, in its own fields, everything needed to decide who may read it.

Which means the tenant identifier is not an implementation detail you can normalise away. It goes on the document. On every document. Denormalised, duplicated, present at every level of the tree, because the alternative is a rule that cannot decide without a lookup, and a rule that does a lookup on every document read is both slow and billed.

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

    // Deny by default. Everything below is a deliberate exception.
    match /{document=**} {
      allow read, write: if false;
    }

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

    // A tenant's records. The tenant id is on the document because the rule
    // cannot go and look it up.
    match /clients/{clientId} {
      allow read: if signedIn() && resource.data.tenantId == tenant();

      allow create: if signedIn()
                    && request.resource.data.tenantId == tenant()
                    && claims().role in ['owner','manager'];

      allow update: if signedIn()
                    && resource.data.tenantId == tenant()
                    && request.resource.data.tenantId == resource.data.tenantId
                    && claims().role in ['owner','manager'];

      allow delete: if signedIn()
                    && resource.data.tenantId == tenant()
                    && claims().role == 'owner';
    }
  }
}

Three things in there are worth pulling out, because each of them is a bug I have seen in production code somewhere.

Deny by default, explicitly, at the top

Firestore already denies what you do not allow, so the catch-all match /{document=**} block is technically redundant. I write it anyway. It states the posture in the file, so that the person reading the rules in eighteen months — who may be me — knows that everything below is an enumerated exception rather than a partial list of restrictions on an otherwise open database. Rules files are read under pressure. Make the default legible.

The update rule pins the tenant id to its old value

request.resource.data.tenantId == resource.data.tenantId is the line people leave out. Without it, a user with legitimate write access to their own document can move that document into another tenant, or into no tenant. They own the record; nothing stops them changing the field that says whose it is. It is a quiet privilege escalation dressed as an ordinary edit, and it exists in a lot of rule files.

Generalise it: a field the server owns must be immutable from the client, and immutable means you compare the incoming value to the stored one, in the rule. Tenant id, owner uid, role, status, price, created timestamp. Every one of them.

Roles come from token claims, never from a document

The tempting shape is a users/{uid} document with a role field, read by the rule via get(). It reads nicely. It also costs a document read on every single evaluation, which is billed and which slows every query, and it creates a chicken-and-egg problem about who may write that document.

Custom claims are the right home for authorisation facts. They are minted server-side with the Admin SDK, they are signed into the ID token, the client cannot forge them, and reading them in a rule is free. The cost is that claims are baked into the token at issue time, so a role change does not take effect until the token refreshes. That is a real trade-off and it is the correct one: a role change that takes effect within the hour is fine, a lookup on every document read is not. Where immediate revocation genuinely matters, revoke the refresh tokens rather than moving authorisation back into a collection.

The list query is where the model actually bites

Here is the part that surprises people coming from SQL, and it is the single most important thing to understand about this model.

Rules do not filter query results. They validate them.

If you write collection('clients').get() with no constraint, Firestore does not quietly hand back the subset you are allowed to see. It evaluates whether the query is guaranteed to return only permitted documents, and if it cannot prove that, it rejects the whole thing. Your client gets a permission error, not a short list.

// Rejected. The rule cannot prove this query is safe.
db.collection('clients').get()

// Allowed. The constraint in the query matches the constraint in the rule.
db.collection('clients').where('tenantId', '==', myTenantId).get()

Developers meet this as an obstacle and go looking for a way around it. It is not an obstacle. It is the feature. The engine is forcing the query to state its scope up front, which is precisely the property that makes the security boundary auditable: you cannot write an unscoped query and get away with it, because it fails loudly in development rather than silently in production.

It also has a consequence worth naming, since it is where the security model and the cost model turn out to be the same model: because every list query must be scoped and bounded, you never end up with the "load the whole collection and filter in the browser" screen. That pattern is the most common source of both data over-exposure and runaway read bills. The rules engine makes it impossible before your invoice makes it obvious.

Aggregate documents, and letting several writers share one

Sometimes a design genuinely calls for many users writing into a single shared document — a public directory, a roster, an index that must be readable in one read rather than one read per row. The instinct is that a shared document cannot be safe. It can, because rules can inspect exactly which keys a write touches.

match /directory/{shard} {
  allow read: if true;                       // public, and it is one read
  allow update: if signedIn()
                && request.resource.data.diff(resource.data)
                     .affectedKeys().hasOnly([request.auth.uid]);
}

Every participant owns exactly one key, named for their uid, inside a document everybody can read. Nobody can edit anybody else's entry, and nobody can wipe the document, because any write touching a key that is not theirs fails. One document, many writers, no trust between them.

The caveat belongs next to the rule, in a comment, in the file: a Firestore document has a hard size limit, so this shape needs a plan for splitting before it approaches it — by locality, by alphabet, by whatever the natural partition is. Write down the number at which the split becomes due. An optimisation with no stated trigger is either premature or forgotten.

Test the rules, not the app that calls them

A rules file is a program. It has branches, it has an ordering, and it will happily contain a condition that is never reachable. Reading it and agreeing that it looks right is not testing.

Firestore has a rules test API that evaluates a rules file against a synthetic request and tells you whether it would allow or deny — no data, no emulator state, no account, nothing billed. That matters more than it sounds, because it means the answer to "can tenant B read tenant A's client record" is settled by a test that costs nothing and can run on every commit, rather than by someone signing in as two users and eyeballing it.

The suite I care about is small and it is mostly negative cases:

  • An unauthenticated request is denied on every path. Every one, not a sample.
  • A signed-in user from tenant B is denied a read of a tenant A document.
  • A signed-in user cannot change tenantId, role, ownerUid or any other server-owned field on a document they otherwise own.
  • A user with the lower of two roles is denied the write the higher role is allowed.
  • An unscoped list query is denied.
  • A write to the shared aggregate touching somebody else's key is denied.

Positive tests confirm the product works. Negative tests confirm the product is safe, and they are the ones that fail usefully when somebody relaxes a rule to unblock a feature. Which somebody will, including me, at some point, at speed, on a Friday.

The rule I actually hold myself to

Never widen a rule to make a feature work.

It is worth stating baldly because the pressure is real and it always arrives disguised as something reasonable. A screen does not load. The rule is denying it. Loosening the rule takes ten seconds and the screen works. And now the boundary that protects every tenant has a hole in it shaped like one afternoon's convenience, with a comment above it saying it is temporary.

When a rule denies something the product legitimately needs, the rule is usually right and the data model is wrong. The document is missing the field the rule needs to make its decision. The query is unscoped. Authorisation lives in a collection instead of a claim. Fixing the model takes longer and it leaves the boundary intact, and the boundary is the only part of this that cannot be repaired after the fact.

The version of this I like best is the one where the security posture and the operating cost turn out to be the same discipline. An application that holds nothing it does not need, reads only the slice a specific signed-in user is entitled to, and cannot express an unbounded query, is cheap to run for the same reason it is hard to breach. There is no bulk surface. Not because someone is guarding it, but because the shape of the system does not have one.

Written by Liana Grigory, Entrepreneur and Software Engineer, from work on The Care Royal, Tegula Stone and Unified Savers. Everything above describes decisions actually made on those systems, including the ones that turned out to be wrong.

Home · Terms of Use · Privacy Notice