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.
Learning outcomes
Explain query authorization as a proof over the query’s possible result set rather than row-by-row post-filtering.
Design AtlasMart order queries whose filters and cardinality constraints satisfy the Security Rules contract.
Predict failure for broadened, OR/in, missing-limit, guessed-ID, and cross-tenant requests before running them.
Distinguish Standard Native Core query/rule semantics from Enterprise Pipeline constraints, server IAM, and App Check.
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.
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.
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.
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; } }}
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.
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.
// 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.
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.
// 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();
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
{ "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();
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
- Why does a tenant-only query fail for Alice?
- Does limit(10) make an unauthorized query safe?
- Why can an in query fail when one value is allowed?
- What is different about Enterprise Pipeline rules?
- 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
- 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