Chapter 12 · Security Rules Foundations: Authentication Context, Match Paths, Reads/Writes, and Validation
Security Rules Execution Model, request / resource, Auth Context, and Rules Are Not Filters
Build AtlasMart Security Rules from deny-by-default and prove request/auth/resource semantics, whole-query authorization, and client-versus-server trust boundaries.
Learning outcomes
Explain the Security Rules request authorization model and distinguish authentication, authorization, validation, App Check and IAM.
Use request, resource and request.resource correctly for create/read/update/delete reasoning.
Prove with deterministic tests that Security Rules authorize an entire query rather than filtering unauthorized documents out of its result set.
Distinguish mobile/web client enforcement from privileged Admin/server execution and identify the Enterprise Pipeline rule surface.
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: signed in is not the same as allowed
AtlasMart lets a customer read her profile and orders, publish a
review, and browse public reviews. A support agent can inspect
tenant orders. An attacker can also call the same Firebase
client SDK directly, skip the UI, invent fields, change document
paths and issue broader queries. Security Rules therefore sit on
the database request path, not in the screen. Firebase
Authentication supplies identity evidence in
request.auth; Rules decide whether the particular
database operation is allowed; validation checks the proposed
document shape. App Check is a separate abuse-resistance signal
about the app instance, not a substitute for user authorization.
Trusted server code uses IAM instead of Rules.
| Term | Meaning in this chapter | Common confusion |
|---|---|---|
| Authentication | Who the client claims to be, represented by request.auth | Does not itself grant access |
| Authorization | Whether this identity may perform this database operation | Must be expressed in Rules for mobile/web clients |
| Validation | Whether proposed data satisfies shape/value/invariant constraints | UI validation is not sufficient |
| resource | Existing stored document for operations where one exists | Not the proposed update |
| request.resource | Prospective document after a write succeeds | For update it represents the complete resulting document |
| IAM | Authorization for server/Admin libraries and Google Cloud identities | Security Rules do not constrain privileged server libraries |
| App Check | Attestation signal that requests come from your app/integrity context | Not user authorization |
2. Rules evaluate a request before data is returned or committed
For a mobile/web SDK request, Firestore identifies the path and
operation, evaluates every matching
allow expression, and permits the operation if at
least one matching allow condition is true. There is no explicit
“deny override”: an overlapping broad
allow true can accidentally defeat a narrower false
condition. Start from no grants and add explicit narrowly-scoped
allows.
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; } }}
The catch-all match /{document=**} is intentionally
false. It documents the deny-by-default policy; unmatched
requests are denied even without it, but the explicit block
makes review intent obvious.
allow read, write: if true makes the database
callable by any client that can reach it. “The UI does not
expose that button” is not a security boundary. Repair by
restoring deny-by-default rules, adding path/identity/data
conditions, and proving both allowed and adversarial cases
before deployment.
3. request.auth and token claims
request.auth is null for unauthenticated requests.
When Firebase Authentication is present it includes the UID and
token claims. AtlasMart uses a synthetic
tenantId and role claim in emulator
tests. A client cannot make Rules trustworthy by writing
role: "admin" into its own Firestore document and
then expecting that field to be authoritative; authorization
data needs a controlled source. Chapter 13 goes deeper into
claims lifecycle and App Check.
await assertFails(getDoc(doc(anon, "profiles/u-1001")));await assertSucceeds(getDoc(doc(alice, "profiles/u-1001")));await assertFails(getDoc(doc(alice, "profiles/u-2001")));await assertSucceeds(getDoc(doc(staff, "orders/o-1001")));
4. Rules are not filters
A query is allowed only when Firestore can prove from its
constraints that every possible returned document satisfies the
rule. Rules do not read a broad result set and then hide
forbidden rows. With allow list requiring
resource.data.customerId == request.auth.uid, Alice
must issue a query constrained to her UID. A query for all
orders is rejected because it could include Noah’s order.
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();
const ownOrders = query( collection(alice, "orders"), where("tenantId", "==", "t-acme"), where("customerId", "==", "u-1001"));await assertSucceeds(getDocs(ownOrders));await assertFails(getDocs(collection(alice, "orders")));
This test proves compatibility between this query and this ruleset in the emulator. It does not prove the production composite index has been deployed or that the query’s production latency/cost meets an SLO.
Client-side filtering happens after authorization and reads. A broad query that cannot be proven safe is denied; weakening rules to make it succeed turns the client into the security boundary. Redesign the query/model so the server can prove the result set.
5. Create, update and delete see different state
On create there is no existing resource; validate
request.resource.data. On update,
resource.data is the before-state and
request.resource.data is the complete after-state.
On delete, resource.data exists but there is no
future document to validate. That difference is why collapsing
every write into allow write often hides policy
mistakes.
await assertSucceeds(updateDoc(doc(alice, "profiles/u-1001"), { displayName: "Ava M."}));await assertFails(updateDoc(doc(alice, "profiles/u-1001"), { tenantId: "t-evil"}));await assertFails(deleteDoc(doc(alice, "profiles/u-1001")));
6. Standard, Enterprise, Core, Pipeline and MongoDB-compatibility boundaries
The mandatory lab is Standard edition, Native mode, Core operations. Enterprise Native mobile/web access can also use Security Rules. Enterprise Pipeline operations have a richer query engine and their Rules engine recognizes a constrained set of comparison/logical filters for proving access; query properties available to Core rules are not all available in Pipeline rule checks. Do not copy a Core rules/query proof mechanically into a Pipeline. Firestore with MongoDB compatibility uses a different application/API compatibility surface and should not be treated as this mobile/web Security Rules lab. Server client libraries remain privileged and use IAM.
7. Reproducible AtlasMart lab
{ "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; } }}
{ "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": []}
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();
Run the emulator, then execute the identity, profile-update and query assertions. Record the matrix below in the repository as the expected contract. The important artifact is not “tests are green”; it is a reviewable mapping from identity × path × operation × data/query shape to expected authorization.
| 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 |
node rules.test.mjs# Expected: all assertSucceeds/assertFails checks match the matrix.# Reset between runs because emulator data persists during a running emulator process.
Production judgment
Least privilege starts with a small set of explicit client
capabilities. Measure Rules complexity by how easily reviewers
can predict a request outcome, not by line count. Keep
privileged server paths separate in threat models. Security
Rules can add document reads through
get()/exists(), which affects billing and limits in
production. Client cache/offline behavior can show previously
cached data even when a later server request would now be
denied, so do not describe a rule change as remote device
erasure. Backup/recovery, audit retention and incident response
remain separate controls.
Lesson 2 focuses on the path matcher itself: nested matches, wildcards, recursive wildcards, cross-document access and their hard evaluation limits.
Knowledge check
- Does request.auth != null mean the user is authorized?
- What does request.resource represent during an update?
- Why does a broad orders query fail when only own orders are allowed?
- Do Admin/server SDK requests obey Cloud Firestore Security Rules?
- What does an emulator rules test prove?
Review the answers
1. No. It proves an authenticated identity exists; authorization still needs path/data/role conditions.
2. The complete document state that would exist after the update succeeds.
3. Rules are not filters; Firestore must prove every possible query result satisfies the rule.
4. No. Privileged server libraries bypass Rules and are secured with IAM.
5. The specified request against the loaded rules and emulator fixture behaves as asserted; it does not prove production IAM, indexes, latency, cost or App Check.
Summary
Security Rules are executable authorization/validation policy on mobile/web Firestore requests. AtlasMart now has a deny-by-default model, explicit identity/data conditions and a query contract that demonstrates “Rules are not filters.”
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