Six roles, fourteen tenant clinics, and a single allow block standing between one clinic's receptionist and another clinic's patient files.
That was the shape of the access-control problem on Callidus, a UK aesthetic-clinic platform I built solo between February and late April 2026 and kept in production until June. Patient records, consent forms, injection histories. All of it GDPR-regulated personal data, all of it sitting in one Firestore database shared by every clinic on the platform. Firestore security rules in a multi-tenant system are the last gate that runs on someone else's machine, and by the time a request reaches them, every check your React app performed is already a suggestion.
The tutorials get you to a working rules file in about twenty minutes. Getting to one you would put patient data behind takes considerably longer, and most of the extra distance is spent on four things nobody demos.
What makes Firestore security rules hard in a multi-tenant app?

The hard part is not writing the rule that allows correct access, it is proving that every path you never considered denies the wrong one.
A single-tenant app has one question to answer: is this user signed in and do they own this document? A multi-tenant one has two, and the second question is the expensive one. Does this user belong to this tenant, and within that tenant, does their role reach this particular kind of data? On Callidus that meant six roles with genuinely different reach — super admin, owner, admin, manager, practitioner, receptionist. A receptionist schedules appointments and must never open a clinical note. A manager sees financial data that a practitioner has no business reading.
Everything lives under tenants/{tenantId}/…, so the tenant boundary is a path segment rather than a field you have to remember to filter on. That choice does most of the work. A rule that scopes on the path cannot be defeated by a forgotten where clause in some query you wrote nine months later, because the document simply is not reachable at that address.
The roles are harder, because roles change. Someone gets promoted. Someone leaves. And that is where the design decision that matters most shows up.
Should tenant roles live in custom claims or a get() lookup?

Put roles in custom claims, because the claim already rides inside the request's token, while a get() lookup inside your rules is a billed read.
That last part surprises people, so it is worth being precise about it. Firebase's own documentation on writing conditions for security rules states that get(), exists() and getAfter() execute a read in your database and "you will be billed for reading documents even if your rules reject the request." Denied traffic costs money. A brute-force attempt against a clinic's patient collection bills you for every rejection.
There is a ceiling too. The same documentation caps document access calls at 10 for single-document and query requests, and 20 for multi-document reads, transactions and batched writes, with the 10 still applying to each operation inside a batch. Cross it and the request fails with permission denied — not a helpful error naming the limit, just a denial that looks exactly like a genuine access violation. Debugging that at 11pm is its own kind of education.
| Roles in custom claims | Roles via get() inside rules | |
|---|---|---|
| Cost per evaluation | Free, already in the token | A billed read, denials included |
| Per-request ceiling | None | 10 calls, or 20 across a transaction |
| Freshness after a role change | Stale until the token refreshes | Immediate |
| Size ceiling | 1,000 bytes of claims, total | Whatever the document holds |
| Failure when you exceed it | Admin SDK throws on write | Permission denied at read time |
Claims are not free of cost, just free of billing. Firebase caps the custom claims payload at 1,000 bytes and is blunt about scope in the custom claims documentation: use them "to store data for controlling user access only." So Callidus carries tenantId and role in the token and nothing else. Not a permission matrix, not a feature-flag bundle. Two fields.
The real tax is freshness. A changed claim reaches a signed-in user when their ID token next refreshes, which is roughly hourly, or immediately if you force it with getIdToken(true). Demote someone and they keep their old reach for up to an hour unless you go and take it from them. For a clinic revoking a departing practitioner's access to patient notes, an hour is not an acceptable answer, so the demotion path forces the refresh rather than waiting for one.
Never write an access decision twice

Inline role checks rot. The moment your access matrix changes, and on a six-role product it changes constantly, every place you spelled out role === 'manager' || role === 'owner' becomes a separate thing to find and update. You will miss one.
Callidus is built around access helpers instead: hasClinicalAccess, hasManagerAccess, hasAdminAccess. Every gate in the codebase reads through them. When the matrix shifted, the change landed in one function and propagated everywhere, rather than drifting apart across forty call sites until two screens disagreed about what a manager is allowed to see.
The same instinct applies inside the rules file. Rules support functions, and a file that defines isTenantMember() and hasClinicalAccess() once at the top reads like the access matrix it implements. Spell the same tenant check out inline across twenty allow blocks instead and you have twenty places for it to drift, with no compiler to tell you when three of them stopped agreeing.
Every mutation on Callidus also passes a !isTenantArchived() guard. A clinic that has been archived should not accept writes even from a legitimately authenticated owner with a valid role, because "your subscription ended" and "you are not allowed" are different questions with the same answer at the rules layer.
Rules ship slower than your fix does
Deploying a rules fix is not instant. Firebase's get-started guide says updates take up to a minute to reach new queries and listeners, and up to 10 minutes to reach active ones.
A clinic dashboard left open all morning is an active listener. Your fix is live; their session still runs the old rules. Plan the incident around ten minutes, not your deploy log.
Does 100% rules coverage mean your tenants are isolated?
No — coverage tells you which rule expressions your tests executed, and nothing about whether a real tenant can reach another tenant's data.
Callidus finished with 100% Firestore rules coverage inside roughly 1,200 tests across Vitest and Playwright, and I am still careful about how I say that number out loud. Firebase describes its rules coverage reports as showing how frequently each test case used a rule, "like traditional 'line coverage' techniques." Line coverage. A rule that reads allow read: if request.auth != null can be 100% covered by a single test and still hand every clinic's patient list to every other clinic.
Actually, let me put that more usefully. Coverage is a map of what you tested, so it is excellent at finding the rule you forgot to exercise and useless as evidence that the rules are correct. The evidence has to come from tests shaped like attacks.
Which means seeding data that genuinely belongs to somebody else. Most rules test suites mock an auth object, point it at a document the test itself created, and assert a happy path. That proves your rule parses. To prove isolation:
- Seed two full tenants in the emulator with real patient and invoice documents, using the admin context that bypasses rules.
- Authenticate as each of your six roles inside tenant A.
- Assert that every one of them is denied on tenant B's documents. Denied, specifically. An empty result set is not a denial, and a query that returns nothing because the collection happens to be empty will pass a sloppy assertion forever.
- Repeat for writes, and separately for the archived-tenant and grace-period states, because those change the answer.
- Run the whole thing against the emulator in CI with @firebase/rules-unit-testing, on every pull request, not nightly.
Step 3 is the one that catches real bugs. You have written the test that asserts your own user can read their own data. Have you written the one that asserts a stranger cannot?
Since 4 September 2026 there is also a Security Rules simulator in the Google Cloud console for Firestore Standard and Enterprise, per the Firestore release notes, which lets you evaluate draft rules and auth tokens against simulated requests before deploying. Useful for a quick sanity check on a rule you are drafting. It does not replace the isolation suite, because it tests the request you thought to type.
The client gate is never the only gate
Route guards in your React app are a user-experience feature. They are not security, and the distance between those two statements is where most multi-tenant leaks live.
On Callidus, a tenant inside the billing grace period can still open their pages and read their data, but writes are refused at both the client route gate and the Firestore rules layer. Two independent layers, deliberately redundant, so neither one is a single point of bypass. Disable JavaScript and the rules still hold. Strip the route guard out of the bundle and the rules still hold.
There is a third door people forget entirely. Firebase's guidance on fixing insecure rules notes that the server client libraries bypass Security Rules completely and authenticate through Application Default Credentials instead. Every Cloud Function you write sits on the far side of your rules, and Callidus runs over a hundred of them. The rules file you spent a week hardening does not apply to a single line of that code. IAM and your own function logic are the control there, which is why the public booking and consent endpoints are guarded by App Check rather than by anything in the rules file.
If you are weighing this data layer against Postgres, I wrote a separate head-to-head on Firestore rules versus Postgres RLS that covers the choice itself rather than the production detail. The short version is that both enforce in the database and the deciding factor is rarely the enforcement model. For the surrounding stack decisions, the React and Firebase integration notes cover how this fits together in practice, and the multi-tenant SaaS tooling overview covers what else you end up needing once the isolation question is settled.
What I would tell someone starting this week
Put the tenant ID in the path, not in a field. Carry tenantId and role as custom claims and keep everything else out of the token. Define your access helpers once, in the rules file and in the app, and never spell a role check out inline. Then write the cross-tenant denial suite before you write the features, because it is the only artefact in the whole build that tells you the isolation is real.
One test tonight, then. Open your rules test file and search it for a case where the authenticated user belongs to a different tenant than the document. If it is not there, that is the gap between a rules file that parses and a rules file that holds — and it takes about an hour to close. What does your suite say about the multi-tenant boundary you already shipped?
