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.

Intermediate165–195 minutesRules tests · CI contractFirebase JS 12.19.0 · Admin 14.4.0 · CLI 15.30.0 · Rules tests 5.0.2Last reviewed: September 2026

Learning outcomes

01

Build an emulator-based Security Rules suite with authenticated, unauthenticated and custom-claim contexts.

02

Seed fixtures without weakening production rules by using the test environment’s rules-disabled setup context.

03

Test create/get/list/update/delete allow and deny cases, including rule-compatible and rule-incompatible queries.

04

Retain a coverage matrix and isolate/reset emulator state so security regressions are deterministic in CI.

05

Know what emulator tests do not prove and identify production-only checks for IAM, App Check, billing and incident telemetry.

Execution and safety note

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.

Chapter 12 reproducibility baseline · reviewed 16 September 2026

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.

Security evidence boundary

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.

package.json
{  "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"  }}
firebase.json
{  "firestore": {    "rules": "firestore.rules",    "indexes": "firestore.indexes.json"  },  "emulators": {    "firestore": { "port": 8080 },    "auth": { "port": 9099 },    "ui": { "enabled": true, "port": 4000 }  }}
local setup
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
firestore.rules · deny by default with explicit AtlasMart grants
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; }  }}
rules test setup and deterministic seed
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

rules.test.mjs · core cases
// 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

valid and invalid profile creation
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.

boundary query tests
// 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.

test lifecycle
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.

expected regression evidence
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

firestore.indexes.json
{  "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": []}
run under emulators:exec
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

  1. Why use withSecurityRulesDisabled during seed setup?
  2. Why test get and list separately?
  3. Can an empty result set rescue an under-constrained query?
  4. What proves that a negative test is valuable?
  5. 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

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.