Security rules are the only thing standing between a Firestore client SDK and your entire database. They are also, in most codebases we review, the only production code with zero tests. That combination — highest consequence, least verification — is why the majority of Firebase security incidents trace back to a rules mistake rather than an exotic exploit.
The fix is mechanical: treat rules as code and test them in the emulator. Here is the setup we use on every engagement.
The harness
The @firebase/rules-unit-testing package runs assertions against the Firestore emulator with an arbitrary authenticated context — meaning you can impersonate any user, or no user, without touching production.
import {
initializeTestEnvironment,
assertSucceeds,
assertFails,
} from '@firebase/rules-unit-testing';
import { readFileSync } from 'node:fs';
let env;
beforeAll(async () => {
env = await initializeTestEnvironment({
projectId: 'demo-rules-test',
firestore: { rules: readFileSync('firestore.rules', 'utf8') },
});
});
afterAll(() => env.cleanup());
beforeEach(() => env.clearFirestore());
Two context helpers cover almost every case: env.authenticatedContext(uid, claims) and env.unauthenticatedContext(). Each returns a Firestore instance that behaves exactly like a client signed in as that identity.
Test the denials, not just the grants
The most common testing mistake is only asserting that legitimate access works. The suite that matters is the one proving illegitimate access fails:
test('a user cannot read another user\'s invoices', async () => {
const alice = env.authenticatedContext('alice').firestore();
await assertFails(getDoc(doc(alice, 'users/bob/invoices/inv-1')));
});
test('unauthenticated clients cannot list the users collection', async () => {
const anon = env.unauthenticatedContext().firestore();
await assertFails(getDocs(collection(anon, 'users')));
});
For every allow in your ruleset, write at least one test of the matching deny: the wrong user, the missing claim, the absent auth. If you cannot write the denial test, you have probably discovered that the rule doesn't actually deny it.
The classic failure modes worth explicit tests
read when you meant get. allow read grants both get (single document) and list (queries). A rule intended to let a user fetch their own profile by ID will also let them query the collection if the rule's condition doesn't reference resource fields that constrain the query. Test list separately from get on every collection that holds per-user data.
Trusting request.resource.data. Anything the client writes is attacker-controlled, including fields like role: "admin" or ownerId. Rules must pin ownership fields to the authenticated identity:
allow create: if request.auth != null
&& request.resource.data.ownerId == request.auth.uid;
allow update: if resource.data.ownerId == request.auth.uid
&& request.resource.data.ownerId == resource.data.ownerId;
That second line — forbidding ownership transfer on update — is the one that's usually missing. Write the test where Alice updates her own document but sets ownerId: "bob", and make sure it fails.
Wildcard depth. match /users/{userId} does not cover /users/{userId}/invoices/{invoiceId}. Subcollections need their own match blocks (or a {document=**} wildcard used deliberately, which is rarely the right call). A test that writes to a nested path you never mentioned in the rules will fail closed — which is correct — but a {document=**} added "temporarily" during development grants everything under the tree. Grep your rules for =** and justify each one.
Validation without authorization. Rules that check the document shape (request.resource.data.keys().hasOnly([...])) but not who is writing it. Shape checks are useful; they are not access control.
Wire it into CI
The emulator runs headless, so the suite belongs in CI next to your unit tests:
{
"scripts": {
"test:rules": "firebase emulators:exec --only firestore 'vitest run rules'"
}
}
emulators:exec starts the emulator, runs the command, and tears down — no daemon management in the pipeline. From that point, a rules change that breaks an invariant fails the build instead of shipping. Deploy rules from the same pipeline (firebase deploy --only firestore:rules) so the tested file is the deployed file; rules edited in the console bypass the whole apparatus, and we recommend treating console edits as an incident.
Coverage that reflects reality
Aim the suite at your access matrix, not at line coverage. Enumerate your roles (anonymous, authenticated, owner, admin, service) and your collections, and write the grid: for each pair, what should get, list, create, update, delete do? The grid usually fits in a page, the suite usually runs in seconds, and writing it uncovers rules you cannot explain — which are the ones that were about to become an incident.
A ruleset with a passing denial suite is not a guarantee. It is, however, the difference between "we believe the rules are right" and "we can demonstrate what the rules do" — and when the data behind the rules belongs to your users, that difference is the job.