Skip to content
SaaS Development14 September 2026 · 8 min readUpdated 30 September 2026

Firestore Rules That Survived a GDPR Clinic SaaS

Firestore security rules are the last gate that runs on someone else's machine. Six roles, fourteen clinic tenants, GDPR-regulated patient records, and what 100% rules coverage does not prove.

Firestore Rules That Survived a GDPR Clinic SaaS

What you'll learn

  • What makes Firestore security rules hard in a multi-tenant app?
  • Should tenant roles live in custom claims or a get() lookup?
  • How do you test Firestore rules for tenant isolation?
Article contents

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?

Two worn paper document folders stacked on a scuffed desk under a brass lamp, the top cover curled back to reveal a clean page underneath, dust drifting in the beam and a coffee ring on the desk

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

A wooden-handled rubber stamp lying on its side on beige paper, the same impression pressed five times in a row beside it, each one slightly more misaligned and faded than the last

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?

A single brass key on a beige surface beside a small paper tag, the tag corner curled, one faint coffee ring overlapping its edge

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 claimsRoles via get() inside rules
Cost per evaluationFree, already in the tokenA billed read, denials included
Per-request ceilingNone10 calls, or 20 across a transaction
Freshness after a role changeStale until the token refreshesImmediate
Size ceiling1,000 bytes of claims, totalWhatever the document holds
Failure when you exceed itAdmin SDK throws on writePermission 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:

  1. Seed two full tenants in the emulator with real patient and invoice documents, using the admin context that bypasses rules.
  2. Authenticate as each of your six roles inside tenant A.
  3. 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.
  4. Repeat for writes, and separately for the archived-tenant and grace-period states, because those change the answer.
  5. 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?

Free resource

Free SaaS MVP Scope Template

A Notion document with the full feature checklist, MVP vs. nice-to-have table, pre-build questions, and cost signals — so you walk into any developer call knowing exactly what to ask for.

Get the template →
DL

Dusko Licanin

Full-Stack Developer · Banja Luka, Bosnia

Full-stack developer shipping SaaS MVPs, web apps, and mobile apps using AI-augmented workflows — without agency coordination overhead. Live portfolio: BookBed, Callidus, Pizzeria Bestek.

Frequently Asked Questions

How do you test Firestore security rules?

Run them against the Firebase emulator with the @firebase/rules-unit-testing library, asserting both what succeeds and what gets denied. The library gives you initializeTestEnvironment, an authenticatedContext for impersonating a signed-in user carrying specific claims, and assertSucceeds / assertFails for the two halves of every case, as Firebase's own security rules testing guide lays out. Seed fixtures through withSecurityRulesDisabled so your setup does not have to satisfy the rules it is about to test. The part most suites skip is cross-tenant denial: authenticate as a user in one tenant and assert failure against a document that genuinely belongs to another. Wire it into CI on every pull request rather than nightly.

Should you put roles in Firebase custom claims?

Yes for anything your rules check on every request, because a claim already rides in the token while a lookup inside rules costs a billed read. Firebase caps the claims payload at 1,000 bytes and says in its custom claims documentation to use claims for controlling access only, so keep them to the few fields your rules actually branch on. On Callidus that meant tenantId and role, nothing more. The cost is freshness. A changed claim only reaches a signed-in user when their ID token refreshes, roughly hourly, unless you force it with getIdToken(true), so any flow that takes access away should force that refresh instead of waiting.

How do you structure a multi-tenant Firestore database?

Put the tenant ID in the document path rather than in a field, so isolation becomes a property of the address instead of something every query must remember to filter. A layout like tenants/{tenantId}/patients/{patientId} lets one rule scope the entire subtree, and a forgotten where clause in a query written months later cannot reach across the boundary, because those documents are not at that address. Carry the same tenantId as a custom claim so rules can compare the path segment directly against the token. Field-based tenancy does work, but it depends on every query being correct forever, which is a harder promise to keep than a path is.

Do Firestore security rules apply to Cloud Functions?

No. Server client libraries and the Admin SDK bypass Security Rules completely and authenticate through Application Default Credentials instead, as Firebase notes in its guidance on fixing insecure rules. Every Cloud Function you deploy sits on the far side of the rules file, so the isolation work you did there protects your web and mobile clients and does nothing for your backend. Access control for that code is IAM plus whatever checks you write inside the function. This catches teams who assume rules are a universal gate, then ship a callable that returns any tenant's data given an ID. Validate the caller's tenant inside the function, and guard public endpoints with App Check.

How long do Firestore security rules take to deploy?

Up to a minute to affect new queries and listeners, and up to ten minutes to fully propagate to listeners that are already active. That gap matters during an incident. A dashboard someone left open before your fix landed is an active listener, so it can keep evaluating the old rules well after your deploy log says the change shipped. Plan the timeline around ten minutes rather than around the deploy. Since September 2026 the Google Cloud console also carries a Security Rules simulator for Firestore Standard and Enterprise, per Google's Firestore release notes, which evaluates draft rules against simulated requests before you deploy.