Chapter 13 · Advanced Security Rules, Query Compatibility, App Check, and Rules Testing

Threat-Model a Client App for ID Enumeration, Query Broadening, Forged Fields, Replay, and Rule Drift

Threat-model the AtlasMart client against enumeration, query broadening, forged fields, replay, claim misuse, App Check bypass attempts, privileged paths, and rule drift.

Intermediate → Advanced170–200 minutesThreat model · abuse tests · responseFirebase JS 12.19.0 · Admin 14.4.0 · CLI 15.30.0 · Rules tests 5.0.2Last reviewed: September 2026

Learning outcomes

01

Build a concrete client-threat model for ID enumeration, query broadening, forged fields, replay, claim misuse, App Check bypass attempts and rule drift.

02

Map each threat to a preventive control, a deterministic abuse test, observable evidence, and an incident response action.

03

Distinguish confidentiality/integrity authorization failures from cost-abuse, replay and privileged-server failures.

04

Decide when Security Rules are sufficient and when a trusted backend, workflow/idempotency layer, or different data model is required.

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 13 reproducibility baseline · reviewed 16 September 2026

AtlasMart continues the same mandatory environment used in Chapters 01–12: 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 remains local/no-cost. The canonical lab is Standard Native Core; Enterprise Native Pipeline differences are labeled explicitly, and Firestore with MongoDB compatibility is not silently treated as the same client/rules surface.

Evidence and trust boundary

The Firestore emulator can prove deterministic evaluation of the rules source it loads, including mock Firebase Authentication identities/claims, query/rule compatibility, field validation, wildcard behavior, and a rules-coverage report. It does not prove production App Check attestation/enforcement, IAM/service-account policy, production index state, billing, abuse resistance, or regional latency. Mobile/web client SDK requests are governed by Security Rules. Admin/server libraries bypass Security Rules and must be constrained through IAM plus application-layer authorization. App Check is an additional app/device attestation signal; it is not user authorization.

1. The AtlasMart adversary: assume the client is programmable

The attacker can read JavaScript bundles, call the Firebase SDK or REST-compatible client surface directly, change every writable field, remove query predicates, enumerate document IDs, replay a previously valid business intent, keep an old ID token until it refreshes, and use a genuine app instance. Threat modeling starts by removing the assumption that screens, form controls, route guards, hidden buttons or TypeScript interfaces are security controls.

Threat Asset at risk Primary control Test/evidence
ID enumeration Private orders/profiles Document Rules owner/tenant checks Guess known fixture IDs and assert denial
Query broadening Cross-user/tenant list data Rule-compatible filters + bounded list Remove customer predicate and assert entire query denial
Forged fields Ownership/role/business state request.resource validation, hasOnly/diff, trusted server transitions Inject customerId/tenantId/admin/status fields and assert denial
Replay Duplicate purchase/coupon/workflow effect Idempotency keys, transaction/workflow state Replay same operation ID and assert same/no duplicate effect
Claim drift/staleness Role/tenant access Trusted claims lifecycle + refresh/revocation strategy Mock old/new claims and staging refresh test
Rule drift Global data exposure Rules CI + code review + deployment discipline Controlled broad-allow regression must fail tests
Untrusted app/script traffic Quota/cost/abuse surface App Check + monitoring + bounded queries Real project metrics/enforcement test

2. Threat 1: ID enumeration

Predictable IDs are convenient for support and logs, but an attacker can try adjacent IDs. Do not rely on randomness for confidentiality. Each direct get must independently verify tenant/owner/role. The abuse test should include a known existing unauthorized document; testing only a missing ID can produce false confidence.

enumeration abuse test
test("enumerated existing order remains private", async () => {  const db = aliceDb();  await assertSucceeds(getDoc(doc(db, "orders/o-1301")));  await assertFails(getDoc(doc(db, "orders/o-1303"))); // exists, owned by Noah  await assertFails(getDoc(doc(db, "orders/o-1304"))); // exists, another tenant});

3. Threat 2: query broadening and post-filtering

Attackers can remove a client filter or alter an in list. The Rules contract must make broadening fail at the backend. Keep one negative test per required query predicate so reviewers know which boundary is protected. If a required UI query cannot be made provably compatible with Rules, change the data model/projection or route the operation through a trusted server that performs application authorization.

broadening abuse variants
// Expected ALLOW: tenant + owner + limit.query(orders,  where("tenantId", "==", "t-acme"),  where("customerId", "==", "u-1001"),  limit(20));// Expected DENY: owner constraint removed.query(orders, where("tenantId", "==", "t-acme"), limit(20));// Expected DENY: unsafe alternative introduced.query(orders,  where("customerId", "in", ["u-1001", "u-2001"]),  limit(20));

4. Threat 3: forged writes

Every client-writable field is attacker-controlled. AtlasMart therefore allowlists fields on create, makes tenant/customer/schema identity immutable on updates, and moves paid/fulfilled/refunded transitions to trusted server workflows. A client may request an action; it should not directly assert that payment succeeded.

forged payload tests
const db = aliceDb();await assertFails(setDoc(doc(db, "orders/o-role-forge"), {  tenantId: "t-acme",  customerId: "u-1001",  status: "DRAFT",  createdAt: new Date(),  schemaVersion: 5,  role: "admin"          // unexpected field}));await assertFails(setDoc(doc(db, "orders/o-owner-forge"), {  tenantId: "t-acme",  customerId: "u-2001", // forged owner  status: "DRAFT",  createdAt: new Date(),  schemaVersion: 5}));

5. Threat 4: replay is a workflow problem

Replaying a valid Firestore create/update is different from reading unauthorized data. Security Rules can validate a request, but they do not automatically make a payment, coupon redemption, email, or external API call exactly once. Chapter 10’s idempotency record and workflow state remain the control. App Check baseline attestation should not be marketed as end-to-end replay protection for Firestore business semantics.

idempotency contract sketch
Client sends operationId = checkout:u-1001:cart-77:v1Trusted workflow transaction:  if operations/{operationId} exists:      return stored result  else:      validate cart/inventory      write operation marker + order/reservation atomically where possibleExternal side effects:  execute from durable workflow/outbox stateDuplicate delivery:  detect operation marker/event ID; do not repeat side effect

6. Threat 5: stale or misissued claims

Custom claims are trusted only because the signed ID token was issued by Firebase after privileged code set the claims. They are not instant configuration. After a role change, existing sessions receive the new claims on a future token issuance/refresh or an explicit force refresh. Build revocation/role-change runbooks around that lifecycle rather than assuming a Firestore Rules edit makes old tokens disappear.

Wrong approach: use claims as a mutable user profile

Custom claims are limited to 1000 bytes and are intended for access control. Large or frequently changing profile data belongs elsewhere. Keep only stable authorization attributes in the token and define how role/tenant changes propagate.

7. Threat 6: App Check bypass attempts and authentic abusive clients

App Check can reject many requests that do not carry accepted attestation when enforcement is enabled. It does not eliminate abuse from a valid app instance, stolen user credentials, or privileged server credentials. Monitor App Check categories, authentication anomalies, Firestore denied requests, cost/usage and application events together. A valid App Check token should never short-circuit the document Rules contract.

Signal Possible interpretation Response question
Unknown origin rises Direct/non-SDK traffic or old clients Did rollout coverage change? Is abuse increasing?
Invalid token rises Bad/misconfigured attestation or hostile client Which app/version/provider is affected?
Permission-denied rises Attack attempts or Rules/query/claim regression Which rule path and release changed?
Reads/cost rises without business activity Broad legitimate query or automated abuse Are query limits/selectivity and App Check enforcement adequate?
Server audit anomalies Privileged path misuse Which service identity/IAM grant was used?

8. Threat 7: rule drift and generated-policy trust

Rules can drift because of a “temporary” broad grant, copied wildcard, new query requirement, auto-generated snippet, or schema migration. Generated rules are input to review, not a trusted authority. Every new grant needs a corresponding positive requirement and at least one negative adversarial test. Keep a changelog/decision note for broad recursive patterns and role expansions.

drift guardrail
Security change review checklist[ ] Which user/business capability requires this new allow path?[ ] Which document/query predicates prove tenant/owner/role boundaries?[ ] Which negative test fails if this grant becomes too broad?[ ] Does the change add get()/exists() calls or claim dependencies?[ ] Does a Core query rule rely on behavior unavailable in Pipeline?[ ] Are server/Admin paths separately protected by IAM/application auth?[ ] Does App Check rollout/enforcement need separate production verification?[ ] Is rollback tested and documented?

9. End-to-end adversarial lab

package.json
{  "name": "atlasmart-firestore-ch13",  "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-ch13 && cd atlasmart-firestore-ch13npm 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 this chapter.npx firebase-tools@15.30.0 emulators:start \  --project demo-atlasmart-firestore \  --only firestore,auth
firestore.indexes.json
{  "indexes": [    {      "collectionGroup": "orders",      "queryScope": "COLLECTION",      "fields": [        { "fieldPath": "tenantId", "order": "ASCENDING" },        { "fieldPath": "customerId", "order": "ASCENDING" },        { "fieldPath": "createdAt", "order": "DESCENDING" }      ]    },    {      "collectionGroup": "orders",      "queryScope": "COLLECTION",      "fields": [        { "fieldPath": "tenantId", "order": "ASCENDING" },        { "fieldPath": "status", "order": "ASCENDING" },        { "fieldPath": "createdAt", "order": "DESCENDING" }      ]    }  ],  "fieldOverrides": []}
firestore.rules · adversarial Chapter 13 baseline
rules_version = '2';service cloud.firestore {  match /databases/{database}/documents {    function signedIn() {      return request.auth != null;    }    function tenantClaim() {      return signedIn() ? request.auth.token.tenantId : null;    }    function roleClaim() {      return signedIn() ? request.auth.token.role : null;    }    function isTenantStaff() {      return signedIn() && roleClaim() in ["support", "admin"];    }    function orderVisibleToCaller() {      return signedIn()        && resource.data.tenantId == tenantClaim()        && (resource.data.customerId == request.auth.uid || isTenantStaff());    }    function validOrderCreate() {      return signedIn()        && request.resource.data.keys().hasAll([             "tenantId", "customerId", "status", "createdAt", "schemaVersion"           ])        && request.resource.data.keys().hasOnly([             "tenantId", "customerId", "status", "createdAt",             "note", "schemaVersion"           ])        && request.resource.data.tenantId == tenantClaim()        && request.resource.data.customerId == request.auth.uid        && request.resource.data.status == "DRAFT"        && request.resource.data.schemaVersion == 5;    }    match /orders/{orderId} {      allow get: if orderVisibleToCaller();      allow list: if request.query.limit <= 25 && orderVisibleToCaller();      allow create: if validOrderCreate();      allow update, delete: if false;    }    match /tenantMemberships/{membershipId} {      allow read, write: if false;    }    match /profiles/{uid} {      allow get: if signedIn() && request.auth.uid == uid;      allow list: if false;      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.diff(resource.data).affectedKeys()             .hasOnly(["displayName", "locale"]);      allow create, delete: if false;    }    match /{document=**} {      allow read, write: if false;    }  }}
rules test setup and deterministic AtlasMart fixtures
import fs from "node:fs";import test, { before, beforeEach, after } from "node:test";import assert from "node:assert/strict";import {  initializeTestEnvironment,  assertSucceeds,  assertFails} from "@firebase/rules-unit-testing";import {  collection, doc, getDoc, getDocs, limit, orderBy,  query, setDoc, updateDoc, where} from "firebase/firestore";let env;before(async () => {  env = await initializeTestEnvironment({    projectId: "demo-atlasmart-firestore",    firestore: {      host: "127.0.0.1",      port: 8080,      rules: fs.readFileSync("firestore.rules", "utf8")    }  });});beforeEach(async () => {  await env.clearFirestore();  await env.withSecurityRulesDisabled(async context => {    const db = context.firestore();    const fixtures = [      ["orders/o-1301", {tenantId:"t-acme", customerId:"u-1001", status:"PAID", createdAt:new Date("2026-09-16T12:00:00Z"), schemaVersion:5}],      ["orders/o-1302", {tenantId:"t-acme", customerId:"u-1001", status:"DRAFT", createdAt:new Date("2026-09-16T12:05:00Z"), schemaVersion:5}],      ["orders/o-1303", {tenantId:"t-acme", customerId:"u-2001", status:"PAID", createdAt:new Date("2026-09-16T12:10:00Z"), schemaVersion:5}],      ["orders/o-1304", {tenantId:"t-other", customerId:"u-9001", status:"PAID", createdAt:new Date("2026-09-16T12:15:00Z"), schemaVersion:5}],      ["profiles/u-1001", {uid:"u-1001", tenantId:"t-acme", displayName:"Ava", locale:"en", schemaVersion:5}],      ["profiles/u-2001", {uid:"u-2001", tenantId:"t-acme", displayName:"Noah", locale:"en", schemaVersion:5}],      ["tenantMemberships/t-acme_u-1001", {tenantId:"t-acme", uid:"u-1001", active:true, role:"customer"}],      ["tenantMemberships/t-acme_staff-1", {tenantId:"t-acme", uid:"staff-1", active:true, role:"support"}]    ];    for (const [path, data] of fixtures) await setDoc(doc(db, path), data);  });});after(async () => { await env.cleanup(); });const aliceDb = () => env.authenticatedContext("u-1001", {  tenantId: "t-acme", role: "customer", plan: "standard"}).firestore();const noahDb = () => env.authenticatedContext("u-2001", {  tenantId: "t-acme", role: "customer", plan: "standard"}).firestore();const staffDb = () => env.authenticatedContext("staff-1", {  tenantId: "t-acme", role: "support"}).firestore();const otherTenantDb = () => env.authenticatedContext("u-9001", {  tenantId: "t-other", role: "customer"}).firestore();
Case Request Expected Why
Q1 Alice: tenantId=t-acme AND customerId=u-1001, limit 20 ALLOW Every possible result is Alice’s order in her tenant and query is bounded
Q2 Alice: tenantId=t-acme only, limit 20 DENY Could return Noah’s order; Rules are not filters
Q3 Alice: customerId=u-1001 with no limit DENY Core list rule also requires request.query.limit ≤ 25
Q4 Alice: customerId in [u-1001,u-2001] DENY One membership alternative can return unauthorized documents
Q5 Staff: tenantId=t-acme, limit 25 ALLOW Role claim authorizes tenant-scoped staff view
Q6 Other-tenant customer: direct get o-1301 DENY Tenant claim does not match stored tenantId
Q7 Alice: direct get guessed o-1303 DENY Predictable IDs do not grant authorization
Q8 Alice: create with customerId=u-2001 or role=admin field DENY Forged identity/business fields do not satisfy create contract
abuse test runner
npx firebase-tools@15.30.0 emulators:exec \  --project demo-atlasmart-firestore \  --only firestore,auth \  "node --test rules.test.mjs"# Then inspect rule traces in Emulator UI:# http://127.0.0.1:4000# And rules coverage while running:# http://127.0.0.1:8080/emulator/v1/projects/demo-atlasmart-firestore:ruleCoverage.html

After the safe suite passes, run the controlled broad-allow mutation from Lesson 4 and verify at least the broad-query and enumeration tests fail. Revert immediately and re-run. Keep no insecure artifact in the final branch.

10. Incident response: security design includes recovery

Incident Immediate containment Follow-up evidence
Rule regression exposes reads Restore last-known-safe Rules; freeze risky client release if needed Rules diff, denied/allowed request traces, access logs/metrics where available
App Check blocks legitimate users Disable/adjust enforcement only through approved rollback; investigate provider/client version App Check metrics by category/app version
Claim misassignment Correct claims in trusted backend; revoke/refresh sessions as policy requires Admin audit trail and token-refresh verification
Privileged service misuse Disable credential/workload, tighten IAM, rotate secrets/keys if applicable Cloud audit/IAM history + application logs
Replay duplicates business effect Stop/reconcile workflow, use operation/event IDs to dedupe/compensate Durable idempotency/workflow records from Chapter 10

Production judgment and bridge to Chapter 14

A secure client design is not “Rules plus App Check.” It is a set of independently testable trust boundaries: identity, authorization, query proof, write validation, attestation, idempotency, privileged backend authorization, observability and recovery. The emulator is the right place for deterministic Rules abuse tests; production-only controls need isolated verification and telemetry.

Chapter 14 moves to the privileged side of the boundary: IAM, Admin/server SDKs, service accounts, Authentication, workload identity and the application authorization that must replace Security Rules when code runs as a trusted backend.

Knowledge check

  1. Why is a known existing unauthorized document better than a missing ID in enumeration tests?
  2. What should happen when an attacker removes customerId from a query?
  3. Can App Check replace idempotency for checkout?
  4. Why are custom claims not instant role storage?
  5. What is the final control when a client access pattern cannot be proven safely by Rules?
Review the answers

1. It proves authorization blocks access to real private data rather than merely observing not-found behavior.

2. The entire query should be denied because its potential result set is broader than the rule permits.

3. No. Business replay/duplicate effects require operation IDs and durable workflow state.

4. They propagate through ID token issuance/refresh and are intended for compact access-control attributes.

5. Remodel the data/query or route it through a trusted backend with explicit application authorization and IAM.

Summary

Chapter 13 ends with an adversarial security contract: predictable IDs stay private, broad queries fail closed, forged writes are rejected, claims and App Check have explicit limits, replay is handled by durable workflow state, and rule drift is caught by CI before release.

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.