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

Secure Queries by Making Query Constraints Provably Compatible with Rules

Make AtlasMart client queries provably compatible with Security Rules, then break the contract deliberately to expose query broadening, logical alternatives, guessed IDs, and Core-versus-Pipeline differences.

Intermediate → Advanced155–185 minutesQuery proof · rule compatibilityFirebase JS 12.19.0 · Admin 14.4.0 · CLI 15.30.0 · Rules tests 5.0.2Last reviewed: September 2026

Learning outcomes

01

Explain query authorization as a proof over the query’s possible result set rather than row-by-row post-filtering.

02

Design AtlasMart order queries whose filters and cardinality constraints satisfy the Security Rules contract.

03

Predict failure for broadened, OR/in, missing-limit, guessed-ID, and cross-tenant requests before running them.

04

Distinguish Standard Native Core query/rule semantics from Enterprise Pipeline constraints, server IAM, and App Check.

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.

Pinned versions and current release check

Firebase JavaScript SDK 12.19.0 was released 9 September 2026; Firebase Admin Node.js 14.4.0 was released 10 September 2026 and carries @google-cloud/firestore 9.1.0; Firebase CLI 15.30.0 was released 9 September 2026. Admin SDK 14.x requires Node.js 22 or higher. Keep these pins in the course lab instead of replacing them with floating latest tags.

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 harmless UI filter is not a security proof

AtlasMart has a “My orders” screen, a support dashboard, and predictable order IDs such as o-1301. The UI knows Alice should see only customerId == u-1001, but an adversarial client can remove that filter, change the tenant, omit the limit, call the SDK directly, or guess another order ID. The Rules engine must authorize the database operation independent of the UI. A secure query therefore has two contracts: the query must express the same data boundary that the rule requires, and the rule must reject requests whose possible result set escapes that boundary.

Layer Question AtlasMart example
Firebase Authentication Who is the caller? UID u-1001 with tenantId=t-acme and role=customer in the ID token
Security Rules May that client request read/write this data? Only own tenant orders; bounded list; strict create shape
Query/index What result set can the backend produce efficiently? tenantId + customerId + createdAt order
App Check Does the request carry accepted app/device attestation? Additional abuse signal when production enforcement is enabled
IAM What can privileged server identities do? Admin/server paths bypass Rules and require IAM + app authorization

2. Potential-result-set reasoning: Rules are not filters

For a collection query, Firestore does not read every candidate document, evaluate Rules per row, and return only the authorized subset. It asks whether the query constraints guarantee that every document the query could return satisfies the rule. This is why a broad query fails even when today’s fixture accidentally contains only authorized rows, and why client-side post-filtering is both too late and unnecessary when the query contract is designed correctly.

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;    }  }}
Wrong approach: query the tenant and filter by customer in JavaScript

A query such as where("tenantId","==","t-acme") can return another customer’s order. The whole query is denied. Weakening Rules so it succeeds turns the client into the authorization boundary. Repair the query so customerId == request.auth.uid is provable, or expose a separately authorized server endpoint/projection.

3. Constrained Core query: filters plus a bounded list

Core Security Rules can inspect data predicates implied by the query and selected request-query properties. AtlasMart also requires request.query.limit <= 25 on list. A missing limit therefore fails the rule rather than silently selecting an unlimited page. The limit is a cost/abuse guard, not a substitute for tenant/customer authorization.

allowed and denied Web SDK query shapes
const ownOrders = query(  collection(db, "orders"),  where("tenantId", "==", "t-acme"),  where("customerId", "==", auth.currentUser.uid),  orderBy("createdAt", "desc"),  limit(20));// Compatible with the owner/tenant predicates and list limit.const tooBroad = query(  collection(db, "orders"),  where("tenantId", "==", "t-acme"),  limit(20));// Denied: could include another customer's order.const unbounded = query(  collection(db, "orders"),  where("tenantId", "==", "t-acme"),  where("customerId", "==", auth.currentUser.uid));// Denied by the explicit request.query.limit <= 25 contract.

request.query in Core exposes limit, offset, and orderBy. Use such properties only for request-shape policy; the document predicates still need to prove authorization. A small limit does not make a cross-tenant query safe.

4. OR, in, and array membership: every alternative must remain safe

Firestore evaluates logical alternatives against the security boundary. If a rule requires customerId == request.auth.uid, then an in list containing Alice and Noah cannot be authorized for Alice. One safe value does not sanitize an unsafe alternative. This matters when UI code builds filters from selectable chips or role-driven lists.

membership boundary
// Alice: safewhere("customerId", "in", ["u-1001"])// Alice: denied because u-2001 is an unauthorized alternativewhere("customerId", "in", ["u-1001", "u-2001"])// The same proof discipline applies to OR/array-contains-any:// every branch/value must be compatible with the rule.

5. Direct gets and ID enumeration

Query compatibility does not replace document authorization. An attacker can guess orders/o-1303 directly. For a single-document get, Firestore evaluates the actual document against orderVisibleToCaller(). Alice’s knowledge of the ID does not grant access. Avoid treating unguessable IDs as the primary control; strong IDs can reduce accidental enumeration, but authorization must still stand when the ID is known.

enumeration test
const alice = aliceDb();await assertSucceeds(getDoc(doc(alice, "orders/o-1301")));await assertFails(getDoc(doc(alice, "orders/o-1303"))); // Noah's orderawait assertFails(getDoc(doc(alice, "orders/o-1304"))); // another tenant

6. Enterprise Native Pipeline: a different proof surface

Enterprise Native Pipeline operations also use Security Rules, but the rules engine recognizes supported comparison and logical filters against constants for constraint satisfiability. Core-style request.query.limit, offset, and orderBy are not available to Pipeline rule checks. In addition, when a Pipeline modifies/derives fields, later filters cannot retroactively prove a rule against the original stored document. Security-constraining where stages therefore belong before field-modification stages.

Pipeline security-ordering concept
// Conceptual Enterprise Pipeline shape:db.pipeline()  .collection("/orders")  .where(eq(field("tenantId"), constant("t-acme")))  .where(eq(field("customerId"), constant("u-1001")))  // Security-constraining filters appear before field modification.  .addFields(/* derived presentation fields */)  .execute();
Edition boundary

The mandatory lab is Standard Native Core and does not pretend to execute Enterprise Pipeline production behavior. Use the deterministic Core rules tests to learn the proof model; verify Pipeline-specific authorization in an isolated Enterprise project when that feature is part of the production design.

7. Reproducible query-security 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();
query-security tests
test("Q1 own bounded query succeeds", async () => {  const db = aliceDb();  const q = query(    collection(db, "orders"),    where("tenantId", "==", "t-acme"),    where("customerId", "==", "u-1001"),    orderBy("createdAt", "desc"),    limit(20)  );  const snap = await assertSucceeds(getDocs(q));  assert.deepEqual(snap.docs.map(d => d.id), ["o-1302", "o-1301"]);});test("Q2 broad tenant query is denied", async () => {  const db = aliceDb();  await assertFails(getDocs(query(    collection(db, "orders"),    where("tenantId", "==", "t-acme"),    limit(20)  )));});test("Q3 guessed document IDs remain protected", async () => {  const db = aliceDb();  await assertSucceeds(getDoc(doc(db, "orders/o-1301")));  await assertFails(getDoc(doc(db, "orders/o-1303")));});
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

Run under emulators:exec, inspect the Firestore > Requests monitor, and save the rules coverage report at http://127.0.0.1:8080/emulator/v1/projects/demo-atlasmart-firestore:ruleCoverage.html. Coverage shows which rule expressions were evaluated; it does not prove you tested every meaningful attack path.

Production judgment

Secure query design is a joint schema/query/rules contract. Optimize for proofability first, then indexes and UX. Keep list queries bounded, preserve explicit tenant/owner predicates, and treat every new query builder as a security-sensitive change. Production SLOs, p95/p99 latency, index deployment, reads/billing, App Check metrics and IAM audit evidence require production/staging observation; do not infer them from the emulator.

Lesson 2 turns the rules themselves into reviewable code: reusable helpers, bounded lookups, caching assumptions, compiler limits, and hidden cost failure modes.

Knowledge check

  1. Why does a tenant-only query fail for Alice?
  2. Does limit(10) make an unauthorized query safe?
  3. Why can an in query fail when one value is allowed?
  4. What is different about Enterprise Pipeline rules?
  5. What does the emulator coverage report prove?
Review the answers

1. Because its possible result set includes orders owned by other users; Rules do not filter those rows out.

2. No. It can bound cardinality/cost, but it does not prove tenant or ownership predicates.

3. Every alternative must satisfy the rule; one unauthorized comparison value makes the query unsafe.

4. They recognize supported comparison/logical filters but do not expose Core request.query limit/offset/orderBy semantics; filter placement before field modifications also matters.

5. Which rule expressions were evaluated during the tests—not production App Check/IAM behavior or completeness of the threat model.

Summary

AtlasMart’s query layer is now an authorization proof, not a UI convention. Filters, request limits, direct gets, logical alternatives and Enterprise query mode are treated explicitly, and intentionally broadened requests demonstrate that unsafe access fails closed.

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.