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 maintained in production through 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.
If you're about to ship a multi-tenant Firestore app, do one thing first: open your rules test file and look for a case where the signed-in user belongs to a different tenant than the document. The rest of this post explains why that single test matters more than any coverage number.
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 is the expensive one. Does this user belong to this tenant, and within that tenant, does their role reach this kind of data? On Callidus that meant six roles with genuinely different reach: super admin, owner, admin, manager, practitioner, receptionist. Each one has distinct access to clinical data, financial data, settings and billing. A receptionist schedules appointments and must never open a clinical note.
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 a query written nine months later, because the document simply is not reachable at that address.
Never write an access decision twice
Inline role checks rot. The moment your access matrix changes, and on a six-role product it changes, 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, so a change to the matrix ripples consistently instead of drifting apart until two screens disagree about what a manager may 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 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. An archived clinic should not accept writes even from an 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.
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.
Firebase's documentation on writing conditions for security rules states that these functions 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 page 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. Some calls may be cached, and cached calls don't count toward the limits. Cross the ceiling and the request fails with permission denied: not an error naming the limit, just a denial that looks exactly like a genuine access violation.
| 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 avoid the billing but carry other costs. The custom claims documentation says a payload over 1000 bytes throws an error and that claims are "only used to provide access control" and are not designed to store additional data. So Callidus carries tenantId and role in the token and nothing else. No permission matrix and no feature-flag bundle: two fields.
The real tax is freshness. The same page lists three ways a changed claim reaches the client: re-authentication, a natural token refresh, or a forced getIdToken(true). Firebase says ID tokens last an hour, so a demoted user can keep their old reach for up to that long unless you force the refresh. For a clinic revoking a departing practitioner's access to patient notes, waiting is not an acceptable answer. My recommendation: make the demotion path force the refresh.
How do you test Firestore rules for tenant isolation?
No coverage number answers that, because 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'm careful about how I say that number. Firebase describes its rules coverage reports as using "traditional 'line coverage' techniques." A rule that reads allow read: if request.auth != null can be 100% covered by one test and still hand every clinic's patient list to every other clinic.
Coverage is a map of what you tested. It's good 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. Many rules test suites mock an auth object, point it at a document the test itself created, and assert a happy path. That shows your rule parses. To test 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 matters most. You've 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. It evaluates draft rules and auth tokens against simulated requests before you deploy. It's handy for a sanity check, but it doesn't replace the isolation suite, because it tests the request you thought to type.
Rules ship slower than your fix does
Firebase's get-started guide says rules updates can take up to a minute to affect new queries and listeners, and up to 10 minutes to fully propagate to active listeners. A clinic dashboard left open all morning is an active listener, so plan an incident around ten minutes, not around your deploy log.
Is a client-side route guard enough for tenant isolation?
No. A route guard is a user-experience feature, and the distance between that and security 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. Strip the route guard out of the bundle and the rules still hold.
There is a third door people forget. Firebase's guidance on fixing insecure rules says "the server client libraries bypass all Cloud Firestore Security Rules and instead authenticate through Google Application Default Credentials." 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 hardened 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're weighing this data layer against Postgres, I wrote a separate head-to-head on Firestore rules versus Postgres RLS. For the surrounding stack, the React and Firebase integration notes show how the pieces fit, and the multi-tenant SaaS tooling overview covers what else you need once isolation is settled.
What I would do 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. Then write the cross-tenant denial suite before the features, because it's the one artefact that tells you the isolation is real.
Tonight, open your rules test file and search for a case where the authenticated user belongs to a different tenant than the document. If it isn't there, that's your gap, and it's an hour's work to close. What does your suite say about the multi-tenant boundary you already shipped?



