The deploy that taught me the lesson
I changed one line in my Firestore security rules. It looked safe — I was tightening a read rule on a collection so only the owning tenant could read its documents. I deployed it, moved on, and found out later that a legitimate read path had started failing. Nothing dramatic broke in a way that paged me. It just quietly stopped working for a real user flow, and I only caught it because I happened to test that flow by hand afterward.
That is the worst kind of bug: a security change that fails closed in the wrong place, silently, with no test to catch it. I had unit tests for my API. I had tests for my scoring logic. I had no tests for the rules that decide who can read and write my data. The rules were the one layer I was editing by feel and verifying by hope.
So I fixed it the boring way: I put my Firestore rules under test in CI, using the Firebase Emulator Suite, and made the deploy pipeline refuse to ship rules that fail those tests. This is the setup I run now, what it caught, and where it still has gaps.
Why rules deserve tests like code
Firestore security rules look like configuration, but they are authorization logic. A single allow read: if line is an access-control decision, and access-control decisions are exactly the kind of thing that should never ship untested. Three properties of rules make this worse than ordinary code:
- They fail silently in production. A wrong rule does not throw an exception in your logs. It returns a permission-denied to a client, and unless you are watching for that specific error, you find out from a user.
- They are easy to get subtly wrong.
request.auth != nullis not the same as "this user owns this document."resource.data.tenantId == request.auth.token.tenantIdonly works if the custom claim is actually set, and if the claim is missing the comparison fails in a direction you need to think about — does it fail open or fail closed? - They change with the data model. Add a field, rename a collection, change how tenancy is represented, and a rule that was correct last month is wrong now. The rule file and the app code drift apart unless something checks them together.
My stack is Firebase Auth with custom claims for tenancy, a FastAPI backend that enforces the same tenant check in middleware, and Firestore as the store. The backend middleware is tested. The rules were not. That asymmetry was the bug.
The setup: emulator + rules unit tests
The Firebase Emulator Suite ships a rules testing library, @firebase/rules-unit-testing, that lets you spin up a Firestore emulator, load your rules file, and make reads and writes as authenticated or unauthenticated users with specific claims. The tests run in Node, against the emulator — no production project involved, no real data at risk.
First, the rules file itself. Here is a simplified version of the shape I test — a multi-tenant collection where each document carries a tenantId, and access requires a matching custom claim:
rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { match /readinessReports/{reportId} { allow read: if isSignedIn() && request.auth.token.tenantId == resource.data.tenantId; allow create: if isSignedIn() && request.auth.token.tenantId == request.resource.data.tenantId; allow update, delete: if false; } function isSignedIn() { return request.auth != null; } } } Two deliberate choices in there. Updates and deletes are denied outright — reports are append-only, and any correction goes through a new report, so the rules enforce that invariant rather than trusting the client. And the tenant check reads the claim from the token, not from a field the client supplies — a client can claim anything in a request body, but it cannot forge a custom claim.
Then the test file. The pattern is: initialize the emulator with the rules, create an authenticated context with a given set of claims, and assert that the operations you expect to succeed do, and the ones you expect to fail do:
import { initializeTestEnvironment, assertSucceeds, assertFails } from "@firebase/rules-unit-testing"; import { doc, getDoc, setDoc } from "firebase/firestore"; import { readFileSync } from "fs"; let testEnv; beforeAll(async () => { testEnv = await initializeTestEnvironment({ projectId: "rules-test-project", firestore: { rules: readFileSync("firestore.rules", "utf8"), host: "127.0.0.1", port: 8080, }, }); }); afterAll(async () => { await testEnv.cleanup(); }); beforeEach(async () => { await testEnv.clearFirestore(); }); test("owner tenant can read its own report", async () => { const alice = testEnv.authenticatedContext("alice", { tenantId: "tenant-a" }); await testEnv.withSecurityRulesDisabled(async (ctx) => { await setDoc(doc(ctx.firestore(), "readinessReports/r1"), { tenantId: "tenant-a", status: "READY" }); }); await assertSucceeds(getDoc(doc(alice.firestore(), "readinessReports/r1"))); }); test("other tenant cannot read it", async () => { const bob = testEnv.authenticatedContext("bob", { tenantId: "tenant-b" }); await testEnv.withSecurityRulesDisabled(async (ctx) => { await setDoc(doc(ctx.firestore(), "readinessReports/r1"), { tenantId: "tenant-a", status: "READY" }); }); await assertFails(getDoc(doc(bob.firestore(), "readinessReports/r1"))); }); test("unauthenticated reads fail", async () => { const anon = testEnv.unauthenticatedContext(); await assertFails(getDoc(doc(anon.firestore(), "readinessReports/r1"))); }); test("a user with no tenant claim cannot read anything", async () => { const noClaim = testEnv.authenticatedContext("mallory", {}); await assertFails(getDoc(doc(noClaim.firestore(), "readinessReports/r1"))); }); The last test is the one I care most about. A user who is authenticated but has no tenantId claim is the exact case that fails in a confusing direction if you write the rule carelessly: request.auth.token.tenantId is null, the comparison is false, and the read is denied. That is fail-closed, which is what I want — but I want it fail-closed because I tested it, not because I assumed it. The test pins the behavior down so a future rule edit cannot quietly flip it. Fixtures are seeded with rules disabled, so test setup never depends on the rules being correct — a broken rule cannot make a test pass vacuously by failing to create the fixture.
Wiring it into CI
Tests that only run on my laptop are a suggestion. The rules tests run in CI on every pull request that touches firestore.rules, the test file, or the Firebase config. The CI job is small:
- name: Install Firebase CLI run: npm install -g firebase-tools - name: Install dependencies run: npm ci - name: Start emulators and run rules tests run: firebase emulators:exec --only firestore "npm run test:rules" firebase emulators:exec starts the emulator, runs the command, and shuts it down — no background-process juggling in CI, no port left listening. The job takes well under a minute for my rule set, which is the whole reason there is no excuse for skipping it.
The second half of the wiring is the deploy gate. The deploy step for Firestore rules runs only if the rules tests passed in the same pipeline. It is easy to end up with a deploy script that runs firebase deploy --only firestore:rules from a laptop on a Friday afternoon, bypassing CI entirely. I removed that path: rules deploy from CI only, on merge to main, after the tests pass.
What it has actually caught
Since I set this up, the tests have caught three kinds of mistakes, all of them mine:
- A claim propagation assumption. I added a new collection and wrote its rule assuming the custom claim would be present on every authenticated user. The no-claim test failed, because a freshly registered user does not get the claim until a backend step assigns it. The rule was correct; my mental model of when claims exist was wrong. I fixed the onboarding flow, not the rule — and I only knew which one was wrong because the test isolated the rule.
- A write-path tenant check I forgot. I added a create rule that checked the claim against the document being created, then refactored the data model so the tenant lived on a parent document instead. The create test started failing because
request.resource.data.tenantIdno longer existed on the new document shape. The old rule would have denied every legitimate create — fail-closed, but broken for real users. - An over-broad read I introduced while debugging. I temporarily loosened a read rule to
allow read: if request.auth != nullwhile chasing a different bug, intending to tighten it back. The cross-tenant test failed immediately in CI. Without the test, that line could have shipped and stayed.
None of these were exotic. All three were the kind of edit you make quickly, confidently, and wrong.
Where the setup still has gaps
Honesty requires saying what this does not cover.
First, rules tests verify the rules, not the backend. My FastAPI middleware does its own tenant check, and the rules and the middleware can disagree. I test them separately, and I do not have a single test that exercises the full path through the API to Firestore with the rules loaded. That end-to-end check is the next thing to build.
Second, the emulator is not production. The rules language is the same, but emulator behavior around edge cases — timestamps, request.time, certain functions — can differ subtly from production evaluation. The tests catch logic errors, not platform quirks. A staging deploy with a smoke test of the real read paths is still necessary.
Third, custom claims take time to propagate. A claim set by the backend is not instantly visible in every token — the user needs a token refresh. My tests use contexts with claims already set, so they never exercise the propagation delay. In production, a user who was just assigned to a tenant can hit a permission-denied until their token refreshes, and that looks identical to a rules bug. I handle it by forcing a token refresh after tenant assignment, but the test suite does not prove that path works.
The pattern is still worth it. It moved Firestore rules from "edited by feel" to "changed with a failing test first," the standard I already held everywhere else in the codebase. The rules were the exception because they looked like config. They are not config. They are the authorization boundary for the data, and they deserve the same discipline as any other security code.
Takeaways
- Firestore rules are authorization logic. Test them like code, not like config.
- Use the Firebase Emulator Suite and
@firebase/rules-unit-testing: seed fixtures with rules disabled, then assert both the allows and the denies. - Always test the cross-tenant deny, the unauthenticated deny, and the authenticated-but-no-claim case — that last one pins down fail-closed behavior.
- Run rules tests in CI on every change to the rules, and deploy rules from CI only, after the tests pass.
- Expect the tests to catch your own wrong assumptions, not just typos — the claim-propagation mistake was a wrong mental model, and only the test showed it.
- Know the gaps: rules tests do not cover backend middleware agreement, emulator-versus-production quirks, or claim propagation delay. Test those paths separately.
