Chapter 12 · Security Rules Foundations: Authentication Context, Match Paths, Reads/Writes, and Validation
Separate create / update / delete / read / list / get Permissions and Validate Required / Immutable Fields
Separate operation permissions and enforce required, allowed, immutable and typed fields against adversarial direct-SDK writes.
Learning outcomes
Separate get/list from read and create/update/delete from write so policy follows operation semantics instead of convenience aliases.
Validate required and optional create fields with keys().hasAll()/hasOnly() and enforce types/ranges for client-supplied values.
Use before/after data and diff().affectedKeys() to protect immutable fields and maintain a narrow mutable-field surface.
Demonstrate malicious direct-SDK payloads that bypass UI assumptions but are rejected by the database rules.
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: one “write” permission hides several different threats
Creating a profile, editing a display name and deleting an
account are different operations. Likewise a single-document
receipt view is not the same exposure as listing an entire
orders collection. Firestore lets read split into
get/list, and write split
into
create/update/delete. Use
the granular operations when the threat or business rule
differs.
| Operation | Existing resource? | Proposed resource? | Typical AtlasMart policy |
|---|---|---|---|
| get | Yes if document exists | No | Owner or authorized staff |
| list | Potential result documents | No | Only queries whose constraints prove every result is authorized |
| create | No | Yes | Owner/tenant + required fields + allowed fields + value validation |
| update | Yes | Yes | Owner + immutable fields unchanged + only approved mutable keys |
| delete | Yes | No | Usually trusted workflow only for governed records |
2. Create validation: require what the schema needs and reject surprise fields
allow create: if request.auth != null && request.auth.uid == uid && 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.displayName is string && request.resource.data.schemaVersion == 4;
hasAll() makes required fields explicit.
hasOnly() rejects unreviewed fields such as
isAdmin or creditLimit. This is schema
validation at the trust boundary, not a replacement for typed
application models; both are useful for different reasons.
An attacker does not need to use your form. They can call the SDK and add fields your UI never renders. Validate the full client-writable shape in Rules.
3. Update validation compares complete before/after documents
On update, request.resource.data is the resulting
complete document, even if the client used a partial update
call. Compare stable identity fields to
resource.data and use
diff().affectedKeys() to define the mutable
surface.
allow update: if request.auth != null && 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"]);
await assertSucceeds(updateDoc(doc(alice, "profiles/u-1001"), { displayName: "Ava M."}));await assertFails(updateDoc(doc(alice, "profiles/u-1001"), { tenantId: "t-evil"}));await assertFails(updateDoc(doc(alice, "profiles/u-1001"), { schemaVersion: 999}));await assertFails(updateDoc(doc(alice, "profiles/u-1001"), { role: "admin"}));
4. Get vs list: least privilege for reads
A customer might be allowed to fetch a known order ID only if it
belongs to them, while listing requires a constrained query. An
internal detail lookup and a collection scan have different
enumeration/exposure characteristics. Separating
get and list lets AtlasMart deny
collection listing even when a specific document can be fetched.
match /profiles/{uid} { allow get: if request.auth != null && request.auth.uid == uid; allow list: if false;}match /orders/{orderId} { allow get, list: if request.auth != null && resource.data.tenantId == request.auth.token.tenantId && resource.data.customerId == request.auth.uid;}
5. Delete deserves its own policy
Chapter 11 showed why deleting live Firestore documents can
conflict with subcollection cleanup, retention and audit
obligations. A client-side allow delete on governed
orders would bypass that lifecycle machinery. AtlasMart
therefore denies order deletes to clients and routes destructive
workflows through trusted server tooling with IAM, correlation
IDs, retention checks and verification.
allow write: if owner also grants delete unless
additional matching structure changes it. Owners may be
permitted to edit presentation fields but not destroy records
needed for fulfillment, refunds, fraud response or retention.
6. Review validation: identity fields immutable, content mutable
match /products/{productId}/reviews/{reviewId} { allow create: if request.auth != null && request.resource.data.authorUid == request.auth.uid && request.resource.data.productId == productId && request.resource.data.rating is int && request.resource.data.rating >= 1 && request.resource.data.rating <= 5; allow update: if request.auth != null && resource.data.authorUid == request.auth.uid && request.resource.data.authorUid == resource.data.authorUid && request.resource.data.productId == resource.data.productId && request.resource.data.diff(resource.data).affectedKeys() .hasOnly(["rating", "text", "visibility"]);}
Rules can enforce values and immutability, but do not rely on them for expensive content moderation, external reputation lookups or arbitrary code. Those belong in trusted services/workflows.
7. Reproducible test matrix
{ "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();
| 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 |
await assertFails(setDoc(doc(alice, "profiles/u-new"), { uid: "u-new", tenantId: "t-acme", displayName: "Fake", role: "admin", schemaVersion: 4}));await assertFails(deleteDoc(doc(alice, "orders/o-1001")));await assertFails(getDocs(collection(alice, "profiles")));await assertSucceeds(getDoc(doc(alice, "profiles/u-1001")));
Retain the matrix in source control. When a schema field changes, update rules and tests in the same change. A document migration that adds a new required field can otherwise make old/new client versions incompatible with the rules contract.
Production judgment
Granular operations and field allowlists reduce blast radius. Prefer a small client-writable schema, stable identity fields and server-controlled business state. Rules are coupled to document shape, so schema evolution requires backward-compatible readers/writers and staged rule changes. Rules tests should include old-client/new-client payloads during migrations, not only the newest happy path.
Lesson 4 composes these primitives into ownership, role, public/private and nested-resource patterns without accidentally granting broad access.
Knowledge check
- Why split write into create/update/delete?
- What does hasOnly() protect against?
- Why compare request.resource.data.tenantId with resource.data.tenantId?
- Why can an owner still be denied delete?
- What should happen when the document schema evolves?
Review the answers
1. The operations have different existing/future state and usually different authorization and lifecycle risks.
2. Unexpected client-supplied fields that were not reviewed as writable.
3. To prevent an update from moving/changing the ownership or tenant boundary.
4. Ownership does not imply permission to bypass retention, fulfillment or audited deletion workflows.
5. Rules and contract tests should evolve in a staged, backward-compatible way with the application schema.
Summary
AtlasMart no longer has a vague “can write” decision. Each operation has an explicit authorization and validation contract, and adversarial payloads prove that client-controlled identity/business fields cannot silently change.
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