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.

Intermediate145–175 minutesOperations · field validationFirebase JS 12.19.0 · Admin 14.4.0 · CLI 15.30.0 · Rules tests 5.0.2Last reviewed: September 2026

Learning outcomes

01

Separate get/list from read and create/update/delete from write so policy follows operation semantics instead of convenience aliases.

02

Validate required and optional create fields with keys().hasAll()/hasOnly() and enforce types/ranges for client-supplied values.

03

Use before/after data and diff().affectedKeys() to protect immutable fields and maintain a narrow mutable-field surface.

04

Demonstrate malicious direct-SDK payloads that bypass UI assumptions but are rejected by the database rules.

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: 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

strict profile create
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.

Wrong approach: validate only what your form sends

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.

protect identity and schema fields
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"]);
adversarial update tests
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.

split read permissions
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.

Wrong approach: allow write to owners

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

review write policy
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

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();
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
field-focused tests
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

  1. Why split write into create/update/delete?
  2. What does hasOnly() protect against?
  3. Why compare request.resource.data.tenantId with resource.data.tenantId?
  4. Why can an owner still be denied delete?
  5. 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

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.