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.
Learning outcomes
Build a concrete client-threat model for ID enumeration, query broadening, forged fields, replay, claim misuse, App Check bypass attempts and rule drift.
Map each threat to a preventive control, a deterministic abuse test, observable evidence, and an incident response action.
Distinguish confidentiality/integrity authorization failures from cost-abuse, replay and privileged-server failures.
Decide when Security Rules are sufficient and when a trusted backend, workflow/idempotency layer, or different data model is required.
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–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.
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.
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.
// 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.
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.
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.
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.
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
{ "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" }}
{ "firestore": { "rules": "firestore.rules", "indexes": "firestore.indexes.json" }, "emulators": { "firestore": { "port": 8080 }, "auth": { "port": 9099 }, "ui": { "enabled": true, "port": 4000 } }}
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
{ "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": []}
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; } }}
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 |
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
- Why is a known existing unauthorized document better than a missing ID in enumeration tests?
- What should happen when an attacker removes customerId from a query?
- Can App Check replace idempotency for checkout?
- Why are custom claims not instant role storage?
- 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
- Securely query data with Cloud Firestore Security Rules
- Writing conditions for Cloud Firestore Security Rules
- Structuring Cloud Firestore Security Rules
- Control access to specific fields
- Test Cloud Firestore Security Rules with the emulator
- Build unit tests for Firebase Security Rules
- Generate Security Rules test reports
- @firebase/rules-unit-testing reference
- Control access with Firebase Authentication custom claims
- Firebase App Check overview
- Enable App Check enforcement
- Monitor App Check request metrics
- App Check with reCAPTCHA Enterprise on Web
- App Check debug provider for Web
- Security Rules for Enterprise Pipeline operations
- Cloud Firestore client/server library trust boundaries
- Cloud Firestore IAM
- Firebase JavaScript SDK release notes
- Firebase Admin Node.js release notes
- Firebase CLI release notes