Chapter 12 · Security Rules Foundations: Authentication Context, Match Paths, Reads/Writes, and Validation
Write Emulator-Based Rules Tests for Allowed and Forbidden Cases Before Shipping Client Queries
Turn the Chapter 12 policy into an emulator-based regression suite covering positive, negative, query and schema-boundary cases before client changes ship.
Learning outcomes
Build an emulator-based Security Rules suite with authenticated, unauthenticated and custom-claim contexts.
Seed fixtures without weakening production rules by using the test environment’s rules-disabled setup context.
Test create/get/list/update/delete allow and deny cases, including rule-compatible and rule-incompatible queries.
Retain a coverage matrix and isolate/reset emulator state so security regressions are deterministic in CI.
Know what emulator tests do not prove and identify production-only checks for IAM, App Check, billing and incident telemetry.
Use the Emulator Suite, a Firebase demo project, or an isolated test project for destructive, security-sensitive, billing-sensitive, migration, backup/restore, or write-heavy exercises unless the lesson explicitly marks managed verification as required. Treat shown output as expected evidence unless it is explicitly identified as captured output, and re-check current Firebase/Google Cloud edition, mode, quota, pricing, and security documentation before production execution.
AtlasMart continues the same mandatory environment used in
Chapters 01–11: project ID
demo-atlasmart-firestore, Standard edition /
Native mode / (default) database, Firestore
emulator 127.0.0.1:8080, Authentication emulator
127.0.0.1:9099, Emulator UI
127.0.0.1:4000, Firebase CLI
15.30.0, Firebase JavaScript SDK
12.19.0, Firebase Admin Node SDK
14.4.0 with
@google-cloud/firestore 9.1.0,
@firebase/rules-unit-testing 5.0.2, and Node.js
22+. Mandatory work is local and no-cost. The canonical lab
uses Standard Native Core operations; Enterprise Native
Core/Pipeline differences are labeled explicitly rather than
silently mixed into Standard semantics.
The Emulator Suite can prove deterministic allow/deny behavior for the rules source loaded into that emulator, mock Firebase Authentication claims, query/rule compatibility, field validation, wildcard matching and many access-call cases. It does not prove production IAM, App Check enforcement, production billing, latency, index deployment state, abuse resistance, or every backend/runtime difference. Mobile/web client requests are evaluated by Security Rules. Admin/server client libraries bypass Security Rules and are authorized by IAM; a passing client-rules test therefore says nothing about privileged server authorization.
1. The AtlasMart problem: manual clicking cannot protect a changing ruleset
Security regressions happen when document fields, queries,
claims or match patterns evolve independently. A developer
changes the orders screen from “my orders” to “tenant orders,” a
migration renames customerId, or a convenient
wildcard is added for a new subcollection. A console simulator
spot check is useful during development, but it is not a
repeatable contract. AtlasMart needs a test suite that fails
before incompatible client/rules changes ship.
| Case | Identity | Operation | Expected | Mechanism proved |
|---|---|---|---|---|
| P1 | anonymous | get profiles/u-1001 | DENY | Authentication required |
| P2 | u-1001 | get own profile | ALLOW | Path ownership |
| P3 | u-1001 | get u-2001 profile | DENY | Authentication ≠ authorization |
| P4 | u-1001 | update displayName only | ALLOW | Mutable-field allowlist |
| P5 | u-1001 | change tenantId | DENY | Immutable field validation |
| O1 | u-1001 | query own orders with customerId == uid | ALLOW | Query proves rule predicate |
| O2 | u-1001 | query all tenant orders | DENY | Rules are not filters |
| O3 | support staff | query tenant orders | ALLOW when rule/query constraints align | Role + tenant authorization |
| R1 | anonymous | get public review | ALLOW | Document condition |
| R2 | u-1001 | create own valid review | ALLOW | Ownership + validation |
| R3 | u-1001 | forge authorUid=u-2001 | DENY | Do not trust client owner field |
2. Test harness: mock Auth, keep rules real
@firebase/rules-unit-testing 5.0.2 creates contexts
whose requests are evaluated by the Firestore emulator’s
Security Rules and lets tests mock Firebase Auth UID/token
claims. Use withSecurityRulesDisabled only for
fixture setup/cleanup; application assertions must use
authenticated/unauthenticated contexts with rules enabled.
{ "name": "atlasmart-firestore-ch12", "private": true, "type": "module", "engines": { "node": ">=22" }, "dependencies": { "firebase": "12.19.0", "firebase-admin": "14.4.0" }, "devDependencies": { "firebase-tools": "15.30.0", "@firebase/rules-unit-testing": "5.0.2" }}
{ "firestore": { "rules": "firestore.rules", "indexes": "firestore.indexes.json" }, "emulators": { "firestore": { "port": 8080 }, "auth": { "port": 9099 }, "ui": { "enabled": true, "port": 4000 } }}
mkdir atlasmart-firestore-ch12 && cd atlasmart-firestore-ch12npm init -ynpm install firebase@12.19.0 firebase-admin@14.4.0npm install --save-dev firebase-tools@15.30.0 @firebase/rules-unit-testing@5.0.2# Save firebase.json, firestore.rules and firestore.indexes.json from the lesson.npx firebase-tools@15.30.0 emulators:start --project demo-atlasmart-firestore --only firestore,auth
rules_version = '2';service cloud.firestore { match /databases/{database}/documents { function signedIn() { return request.auth != null; } function sameTenant(tenantId) { return signedIn() && request.auth.token.tenantId == tenantId; } function isStaff() { return signedIn() && request.auth.token.role in ["support", "admin"]; } function profileCreateShape() { return request.resource.data.keys().hasAll(["uid", "tenantId", "displayName", "schemaVersion"]) && request.resource.data.keys().hasOnly(["uid", "tenantId", "displayName", "locale", "schemaVersion"]) && request.resource.data.uid == request.auth.uid && request.resource.data.tenantId == request.auth.token.tenantId && request.resource.data.schemaVersion == 4; } match /profiles/{uid} { allow get: if signedIn() && request.auth.uid == uid; allow list: if false; allow create: if signedIn() && request.auth.uid == uid && profileCreateShape(); allow update: if signedIn() && request.auth.uid == uid && request.resource.data.uid == resource.data.uid && request.resource.data.tenantId == resource.data.tenantId && request.resource.data.schemaVersion == resource.data.schemaVersion && request.resource.data.diff(resource.data).affectedKeys().hasOnly(["displayName", "locale"]); allow delete: if false; } match /orders/{orderId} { allow get, list: if signedIn() && resource.data.tenantId == request.auth.token.tenantId && (resource.data.customerId == request.auth.uid || isStaff()); allow create: if signedIn() && request.resource.data.customerId == request.auth.uid && request.resource.data.tenantId == request.auth.token.tenantId && request.resource.data.status == "DRAFT" && request.resource.data.schemaVersion == 4; allow update, delete: if false; } match /products/{productId}/reviews/{reviewId} { allow get, list: if resource.data.visibility == "public"; allow create: if signedIn() && request.resource.data.authorUid == request.auth.uid && request.resource.data.productId == productId && request.resource.data.tenantId == request.auth.token.tenantId && request.resource.data.rating is int && request.resource.data.rating >= 1 && request.resource.data.rating <= 5 && request.resource.data.schemaVersion == 4; allow update: if signedIn() && resource.data.authorUid == request.auth.uid && request.resource.data.authorUid == resource.data.authorUid && request.resource.data.productId == resource.data.productId && request.resource.data.tenantId == resource.data.tenantId && request.resource.data.diff(resource.data).affectedKeys().hasOnly(["rating", "text", "visibility"]); allow delete: if signedIn() && resource.data.authorUid == request.auth.uid; } match /{document=**} { allow read, write: if false; } }}
import fs from "node:fs";import { initializeTestEnvironment, assertSucceeds, assertFails} from "@firebase/rules-unit-testing";import { doc, getDoc, setDoc, updateDoc, deleteDoc, collection, query, where, getDocs} from "firebase/firestore";const testEnv = await initializeTestEnvironment({ projectId: "demo-atlasmart-firestore", firestore: { host: "127.0.0.1", port: 8080, rules: fs.readFileSync("firestore.rules", "utf8") }});await testEnv.clearFirestore();await testEnv.withSecurityRulesDisabled(async context => { const db = context.firestore(); await setDoc(doc(db, "profiles/u-1001"), { uid: "u-1001", tenantId: "t-acme", displayName: "Ava", locale: "en", schemaVersion: 4 }); await setDoc(doc(db, "profiles/u-2001"), { uid: "u-2001", tenantId: "t-acme", displayName: "Noah", locale: "en", schemaVersion: 4 }); await setDoc(doc(db, "orders/o-1001"), { tenantId: "t-acme", customerId: "u-1001", status: "PAID", createdAt: new Date("2026-09-16T12:00:00Z"), schemaVersion: 4 }); await setDoc(doc(db, "orders/o-2001"), { tenantId: "t-acme", customerId: "u-2001", status: "PAID", createdAt: new Date("2026-09-16T12:05:00Z"), schemaVersion: 4 }); await setDoc(doc(db, "products/p-1001/reviews/r-001"), { tenantId: "t-acme", productId: "p-1001", authorUid: "u-1001", rating: 5, text: "Works well", visibility: "public", createdAt: new Date("2026-09-16T12:10:00Z"), schemaVersion: 4 });});const alice = testEnv.authenticatedContext("u-1001", { tenantId: "t-acme", role: "customer" }).firestore();const staff = testEnv.authenticatedContext("staff-1", { tenantId: "t-acme", role: "support" }).firestore();const anon = testEnv.unauthenticatedContext().firestore();
3. Turn the matrix into executable assertions
// Identity and direct getsawait assertFails(getDoc(doc(anon, "profiles/u-1001")));await assertSucceeds(getDoc(doc(alice, "profiles/u-1001")));await assertFails(getDoc(doc(alice, "profiles/u-2001")));// Update allowlist and immutable fieldsawait assertSucceeds(updateDoc(doc(alice, "profiles/u-1001"), { displayName:"Ava M." }));await assertFails(updateDoc(doc(alice, "profiles/u-1001"), { tenantId:"t-evil" }));// Lists/queries: prove the result setconst ownOrders = query( collection(alice, "orders"), where("tenantId", "==", "t-acme"), where("customerId", "==", "u-1001"));await assertSucceeds(getDocs(ownOrders));await assertFails(getDocs(collection(alice, "orders")));// Reviews: public read, forged ownership deniedawait assertSucceeds(getDoc(doc(anon, "products/p-1001/reviews/r-001")));await assertFails(setDoc(doc(alice, "products/p-1001/reviews/r-forged"), { tenantId:"t-acme", productId:"p-1001", authorUid:"u-2001", rating:5, text:"forged", visibility:"public", schemaVersion:4}));// Destructive operation remains server-governedawait assertFails(deleteDoc(doc(alice, "orders/o-1001")));
4. Test create separately from update
const newUserDb = testEnv.authenticatedContext( "u-3001", { tenantId:"t-acme", role:"customer" }).firestore();await assertSucceeds(setDoc(doc(newUserDb, "profiles/u-3001"), { uid:"u-3001", tenantId:"t-acme", displayName:"Mina", locale:"en", schemaVersion:4}));await assertFails(setDoc(doc(newUserDb, "profiles/u-3002"), { uid:"u-3002", tenantId:"t-acme", displayName:"Impersonated", schemaVersion:4}));await assertFails(setDoc(doc(newUserDb, "profiles/u-3001-extra"), { uid:"u-3001", tenantId:"t-acme", displayName:"Mina", isAdmin:true, schemaVersion:4}));
The tests intentionally target different failure mechanisms: path/UID mismatch and unexpected field injection. A suite that only checks “some write was denied” can mask why it was denied.
5. Test query compatibility, not just document reads
A get test does not prove a list/query can succeed. Every client query shape should have a matching authorization test. Likewise, a rule change that permits a document read can still leave the intended query unprovable. Store query builders and their rules contract tests near each other when possible.
// Correct owner constraint.await assertSucceeds(getDocs(query( collection(alice, "orders"), where("customerId", "==", "u-1001"), where("tenantId", "==", "t-acme"))));// Missing owner constraint: cannot prove every result is Alice's.await assertFails(getDocs(query( collection(alice, "orders"), where("tenantId", "==", "t-acme"))));// Empty results do not make an unsafe query safe: authorization is based on possible results.await assertFails(getDocs(query( collection(alice, "orders"), where("status", "==", "IMPOSSIBLE_IN_FIXTURE"))));
The last case is especially important: “our seed data happens to return zero” does not make an under-constrained query authorized.
6. Keep tests isolated and deterministic
The Firestore emulator persists data across tests during a
running emulator process unless you clear it. Use
clearFirestore() between independent cases/suites,
deterministic IDs, and rules-disabled seed blocks. Avoid
wall-clock-dependent authorization where possible; when time
matters, define a controlled strategy and test boundaries
explicitly.
import { before, beforeEach, after } from "node:test";let testEnv;before(async () => { /* initializeTestEnvironment(...) */ });beforeEach(async () => { await testEnv.clearFirestore(); await testEnv.withSecurityRulesDisabled(async context => { // Seed only the documents this test requires. });});after(async () => { await testEnv.cleanup(); });
7. CI failure injection: prove the suite catches a regression
Temporarily introduce a dangerous rule such as
allow read: if request.auth != null for profiles.
The test “Alice cannot read Noah’s profile” must fail. Revert
the insecure rule and verify the suite passes. This controlled
regression proves the test has security value rather than merely
exercising SDK calls.
FAIL P3: expected permission-denied for profiles/u-2001, but read succeededDiagnosis: broad authenticated read rule overlaps the owner-only ruleRepair: remove broad grant; rerun suitePASS P3: profiles/u-2001 denied to u-1001
8. What to run in production/staging in addition to emulator tests
| Control | Emulator suite | Production/staging follow-up |
|---|---|---|
| Security Rules logic | Strong deterministic coverage | Deploy/version review; monitor permission-denied trends |
| Firebase Authentication claims | Mock UID/claims | Verify trusted issuance/revocation/token refresh lifecycle |
| App Check | Architecture only / limited emulator fidelity | Enable and monitor enforcement for supported production clients |
| IAM/server paths | Rules-disabled fixture path is not IAM | Least-privilege service identities and audit logs |
| Indexes | Emulator query behavior differs from production requirements | Deploy required indexes and run bounded staging query checks |
| Billing/latency | Not production evidence | Observe real operation/read/access-call cost and latency under bounded test traffic |
9. Reproducible acceptance run
{ "indexes": [ { "collectionGroup": "orders", "queryScope": "COLLECTION", "fields": [ { "fieldPath": "tenantId", "order": "ASCENDING" }, { "fieldPath": "customerId", "order": "ASCENDING" }, { "fieldPath": "createdAt", "order": "DESCENDING" } ] }, { "collectionGroup": "reviews", "queryScope": "COLLECTION_GROUP", "fields": [ { "fieldPath": "visibility", "order": "ASCENDING" }, { "fieldPath": "createdAt", "order": "DESCENDING" } ] } ], "fieldOverrides": []}
npx firebase-tools@15.30.0 emulators:exec --project demo-atlasmart-firestore --only firestore,auth "node --test rules.test.mjs"# Expected: all positive and negative assertions pass.# Keep the test matrix beside the rules file in source control.
Use the same pinned versions in CI. Do not replace them with
floating @latest tags in reproducible course
commands.
Production judgment and bridge to Chapter 13
A rules suite should fail for the right reasons, cover both capabilities and forbidden paths, and evolve with document/query schemas. Track negative tests as first-class security requirements. The emulator is a fast deterministic policy laboratory, but production security also depends on Auth/claims administration, App Check, IAM for privileged services, deployment discipline, logging and incident response.
Chapter 13 advances from foundational authorization to adversarial query compatibility, rule helper design, custom-claim lifecycle, App Check architecture and CI regression testing against forged/broadened requests.
Knowledge check
- Why use withSecurityRulesDisabled during seed setup?
- Why test get and list separately?
- Can an empty result set rescue an under-constrained query?
- What proves that a negative test is valuable?
- What major controls remain outside emulator Rules tests?
Review the answers
1. To create deterministic fixtures without weakening the rules under test; application assertions still run with rules enabled.
2. They are distinct rule operations and query authorization requires proving all possible results.
3. No. Rules evaluate the potential result set implied by query constraints, not only current fixture rows.
4. A controlled insecure rule change makes it fail, and restoring the safe rule makes it pass.
5. Production IAM, App Check enforcement, claim issuance/revocation, deployment/index state, billing, latency, telemetry and incident response.
Summary
Chapter 12 ends with an executable authorization contract: deny by default, explicit path/operation/data grants, query-compatible Rules, immutable-field validation, and repeatable emulator tests for both allowed and forbidden behavior.
Authoritative references
- Get started with Cloud Firestore Security Rules
- Structuring Cloud Firestore Security Rules
- Writing conditions for Cloud Firestore Security Rules
- Securely query data
- Control access to specific fields
- Test your Cloud Firestore Security Rules
- Build unit tests for Firebase Security Rules
- @firebase/rules-unit-testing reference
- Secure data access for users and groups
- Firestore SDKs and client libraries
- Enterprise security overview
- Security Rules for Pipeline operations
- Enterprise Native Core and Pipeline operations overview
- Firebase App Check
- Firestore IAM