Skip to content
SaaS Development14 September 2026 · 10 min read

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

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?

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 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?

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.

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 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 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

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 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:

  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 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?

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.