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.

Intermediate → Advanced150–180 minutesRule functions · access-call budgetFirebase JS 12.19.0 · Admin 14.4.0 · CLI 15.30.0 · Rules tests 5.0.2Last reviewed: September 2026

Learning outcomes

01

Design Security Rules helper functions that improve readability without hiding cross-document reads or authorization scope.

02

Account for get()/exists()/getAfter() access-call ceilings, possible caching, and production billing consequences.

03

Recognize current rules-language complexity limits and avoid architectures that depend on being close to compiler/runtime ceilings.

04

Use emulator traces and coverage to expose hidden lookup repetition, undefined values, and regression paths.

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 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().

alternative rule helper with one controlled lookup
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();}
Wrong approach: hide every lookup behind tiny helpers

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.

one visible lookup in a v2 function
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.

Production rule: limits are not a capacity target

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.

coverage endpoints and deterministic test run
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

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.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;    }  }}
alternative rule helper with one controlled lookup
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();}
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();
membership-focused assertions
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

  1. Why should lookup helpers be obvious by name?
  2. Can you rely on repeated get() calls being cached?
  3. What happens when access-call limits are exceeded?
  4. Why might a custom claim be cheaper than a membership document lookup?
  5. 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

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.