Chapter 13 · Advanced Security Rules, Query Compatibility, App Check, and Rules Testing
Rules Functions, Reuse, Complexity, Access-Call Caching, and Avoiding Hidden Cost / Limit Failures
Refactor AtlasMart Rules into reviewable helpers while making cross-document lookups, caching assumptions, complexity ceilings, billing, and hidden limit failures observable.
Learning outcomes
Design Security Rules helper functions that improve readability without hiding cross-document reads or authorization scope.
Account for get()/exists()/getAfter() access-call ceilings, possible caching, and production billing consequences.
Recognize current rules-language complexity limits and avoid architectures that depend on being close to compiler/runtime ceilings.
Use emulator traces and coverage to expose hidden lookup repetition, undefined values, and regression paths.
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 problem: a clean helper can hide an expensive authorization graph
After Chapter 12, AtlasMart refactors repeated checks into
functions. The rules file becomes shorter, but a helper called
canReadCase() might call
isActiveTenantMember(), which performs
exists() and get(). Calling the helper
several times across overlapping rules can consume
document-access calls, add billed rule reads in production, and
fail with permission-denied when limits are
exceeded. Readability is valuable only if reviewers can still
see the data dependencies.
| Concern | Current documented boundary | Design implication |
|---|---|---|
| Rules document access calls | 10 for a single-document/query request; 20 for multi-document reads/transactions/batches, with per-operation 10 still applying | Count lookups in worst-case authorization paths |
| Access-call caching | Some document access calls may be cached and cached calls do not count | Do not design correctness around an undocumented cache hit |
| Function recursion | Not permitted | Keep helpers acyclic and explicit |
| Function arguments | Maximum 7 | Prefer policy-focused helpers over generic mega-functions |
| let bindings in v2 function | Maximum 10 | Use for clarity, not as a substitute for data modeling |
| Expression evaluations | Maximum 1,000 per request | Avoid enormous generated predicate trees |
| Nested match depth | Maximum 10 | Keep authorization structure navigable |
2. Pure helpers versus lookup helpers
A pure helper depends only on request,
resource, path captures, and values passed to it. A
lookup helper calls get(), exists(),
or getAfter() on another document. Both are
legitimate, but they have different operational cost and failure
modes. Name lookup helpers so reviewers can identify them
quickly; for example
membershipExists() communicates more than
authorized().
function membershipPath() { return /databases/$(database)/documents/tenantMemberships/ $(tenantClaim() + "_" + request.auth.uid);}function isActiveTenantMember() { return signedIn() && exists(membershipPath()) && get(membershipPath()).data.active == true;}match /supportCases/{caseId} { allow get, list: if isActiveTenantMember() && resource.data.tenantId == tenantClaim();}
A chain such as
isStaff() → hasMembership() → loadMembership() →
active()
can obscure repeated reads and make an access-call failure
look mysterious. Repair by consolidating the required lookup,
keeping the path deterministic, documenting the lookup count,
and testing the worst-case operation.
3. Use let bindings to avoid duplicate expressions—not to assume backend caching
Rules v2 permits up to ten let bindings in a
function. A local variable can make one fetched document
reusable inside the function and makes the intended single
dependency visible. The backend may cache repeated access calls,
but the official contract says only that some calls
may be cached. Treat caching as an optimization, never
as the reason a policy fits under the limit.
function activeMembership() { let p = /databases/$(database)/documents/tenantMemberships/ $(tenantClaim() + "_" + request.auth.uid); let m = get(p); return m.data.active == true && m.data.tenantId == tenantClaim() && m.data.uid == request.auth.uid;}
4. Access calls are authorization work and can be billable
In production, document reads performed by
get()/exists()/getAfter()
in Rules are billed reads even when the request is rejected.
That means a denial can still have cost. The emulator has no
billing consequence, so the local lab can validate logic and
limits but cannot estimate a monthly bill. Prefer token claims
or duplicated authorization fields when they are trustworthy and
reduce repeated rule lookups, while considering their own
lifecycle and consistency costs.
| Authorization source | Strength | Cost/freshness tradeoff |
|---|---|---|
| request.auth.token claim | No Firestore Rules lookup; cryptographically carried in ID token | Changes propagate when a new ID token is issued; claims must be issued by trusted code |
| Stored field on requested document | No extra Rules document read | Must be immutable/trusted enough for authorization |
| Separate membership document | Central dynamic membership source | Consumes Rules access reads and creates another availability/data dependency |
| Client-provided field | Cheap | Untrusted; never authoritative by itself |
5. Current complexity limits—and a documentation footnote worth noticing
The current Security Rules language/limits pages list a maximum
function call depth of 20, seven arguments per function, ten
let bindings, no recursive calls, 1,000 evaluated
expressions, ten nested match levels, 100 path
segments across nested matches, and 20 captured path variables.
An older paragraph in the conditions guide still describes a
call-stack depth of 10. Do not design a ruleset near either
boundary. Keep helper depth shallow, compile/deploy in CI, and
re-check the current language/limits pages before relying on a
ceiling.
A ruleset that barely fits a compiler/runtime limit has poor change tolerance. Leave headroom so a security fix can add a predicate or lookup without turning unrelated requests into permission-denied failures.
6. Make hidden lookups observable
The Firestore emulator Requests monitor shows the rules evaluation sequence for each client request, and the rule coverage endpoint breaks rules into expressions/subexpressions with evaluation counts and values. Build one test that exercises the most lookup-heavy allowed path and one that intentionally fails after exhausting a designed lookup budget in a disposable rules variant. The goal is not to “benchmark” the emulator; it is to expose the authorization graph.
npx firebase-tools@15.30.0 emulators:exec \ --project demo-atlasmart-firestore \ --only firestore,auth \ "node --test rules.test.mjs"# While the emulator is running:curl -s http://127.0.0.1:8080/emulator/v1/projects/demo-atlasmart-firestore:ruleCoverage > ruleCoverage.json# Browser view:# http://127.0.0.1:8080/emulator/v1/projects/demo-atlasmart-firestore:ruleCoverage.html
7. Lab: compare claim-based and lookup-based membership
{ "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
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; } }}
function membershipPath() { return /databases/$(database)/documents/tenantMemberships/ $(tenantClaim() + "_" + request.auth.uid);}function isActiveTenantMember() { return signedIn() && exists(membershipPath()) && get(membershipPath()).data.active == true;}match /supportCases/{caseId} { allow get, list: if isActiveTenantMember() && resource.data.tenantId == tenantClaim();}
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();
test("claim-based order rule needs no membership doc", async () => { const db = aliceDb(); await assertSucceeds(getDoc(doc(db, "orders/o-1301")));});test("lookup-based case rule depends on membership state", async () => { // Add a supportCases fixture under rules-disabled setup, then use a rules // variant whose list/get calls activeMembership(). // Assert success while membership.active == true. // Seed membership.active == false and assert permission-denied.});// Also inspect ruleCoverage.json to confirm which get()/exists() expressions// were actually evaluated on each path.
Do not compare emulator request latency as a production p95/p99 benchmark. The meaningful evidence here is which authorization dependencies were evaluated and whether the policy fails safely when membership data is absent/inactive.
8. Rule functions should encode policy vocabulary, not implementation cleverness
Prefer helpers such as sameTenant(),
ownsOrder(), and
validOrderCreate() over generic functions such as
check(a,b,c,d,e). A reviewer should be able to map
each helper to a business security statement. Keep
cross-document reads rare, name them explicitly, and make
negative tests prove that a missing/forged authorization
dependency does not accidentally fall through to another broad
allow.
9. Standard, Enterprise and server boundaries
Security Rules limits apply to the rules engine, but query-proof semantics differ between Core and Enterprise Pipeline as covered in Lesson 1. Admin/server libraries bypass Rules entirely; refactoring a client rule helper does not harden a privileged backend. Service accounts need least-privilege IAM and backend endpoints still need application authorization. Firestore with MongoDB compatibility has its own client/API model and should not be assumed to execute this Firebase mobile/web Rules contract.
Production judgment
Use functions to reduce policy duplication, not to hide complexity. Track the number and purpose of cross-document lookups per operation, keep rules source and tests together, review changes to claims/membership schema as security changes, and monitor production denied-request patterns rather than assuming every denial is malicious. A sudden rise in permission-denied can also mean rule drift or stale claims.
Lesson 3 adds App Check as an abuse-reduction layer and makes its boundary explicit: authentic app/device context is useful, but it cannot answer “is Alice allowed to read Noah’s order?”
Knowledge check
- Why should lookup helpers be obvious by name?
- Can you rely on repeated get() calls being cached?
- What happens when access-call limits are exceeded?
- Why might a custom claim be cheaper than a membership document lookup?
- What is the safe interpretation of the current function-depth documentation?
Review the answers
1. Because cross-document reads consume access-call budget, can be billed, and create another authorization dependency.
2. No. Some calls may be cached, but correctness and limit planning should not depend on it.
3. The request fails with permission-denied.
4. The claim is already in the signed ID token, so Rules do not need an extra Firestore document read; however claim freshness/lifecycle must be managed.
5. Keep functions shallow and leave headroom; current language/limits pages list 20 while an older conditions paragraph still mentions 10.
Summary
Reusable Rules are secure only when their hidden data dependencies remain visible. AtlasMart now treats cross-document authorization calls as a bounded, observable, potentially billable resource rather than a free abstraction.
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