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.

Intermediate150–180 minutesRules execution · auth · query proofFirebase JS 12.19.0 · Admin 14.4.0 · CLI 15.30.0 · Rules tests 5.0.2Last reviewed: September 2026

Learning outcomes

01

Explain the Security Rules request authorization model and distinguish authentication, authorization, validation, App Check and IAM.

02

Use request, resource and request.resource correctly for create/read/update/delete reasoning.

03

Prove with deterministic tests that Security Rules authorize an entire query rather than filtering unauthorized documents out of its result set.

04

Distinguish mobile/web client enforcement from privileged Admin/server execution and identify the Enterprise Pipeline rule surface.

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

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; }  }}

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.

Wrong approach: temporary open rules

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.

identity-focused assertions
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.

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();
query contract: constrained succeeds, broad fails
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.

Wrong approach: fetch everything and filter in JavaScript

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.

before/after update proof
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

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; }  }}
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": []}
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();

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

  1. Does request.auth != null mean the user is authorized?
  2. What does request.resource represent during an update?
  3. Why does a broad orders query fail when only own orders are allowed?
  4. Do Admin/server SDK requests obey Cloud Firestore Security Rules?
  5. 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

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.