Chapter 14 · IAM, Admin/Server SDKs, Service Accounts, Authentication, and Trust Boundaries
Firebase Authentication vs Google Cloud IAM vs Firestore Security Rules: Who Authorizes Which Request Path
Separate Firebase Authentication, Security Rules and Google Cloud IAM by request path, then prove client-rule denial versus privileged server behavior in the AtlasMart emulator.
1. Why AtlasMart needs three different authorization mental models
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 has three operations that look similar from the UI but
cross different trust boundaries. A customer reads
orders/o-9001 directly from a browser. A support
agent presses “Refund,” which calls an AtlasMart backend. A
nightly worker repairs stale projections. All three eventually
touch Firestore, but they do not arrive with the same principal
or pass through the same policy engine. Treating them as
“authenticated Firestore calls” hides the most important
security distinction in the system.
Authentication establishes who a principal is. Authorization decides what that principal may do. Firebase Authentication issues end-user identity tokens. Cloud Firestore Security Rules authorize mobile/web client requests. Google Cloud IAM authorizes privileged server workloads. A backend that accepts an end-user token has two identities in play simultaneously: the caller’s user identity and the backend’s own workload identity.
AtlasMart continues the same mandatory environment used in
Chapters 01–13: 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.js SDK
14.4.0 carrying
@google-cloud/firestore 9.1.0,
@firebase/rules-unit-testing 5.0.2, and Node.js
22+. Mandatory work remains local/no-cost. Standard Native
Core is the canonical lab; Enterprise Native Core/Pipeline and
MongoDB compatibility differences are called out explicitly.
A Firebase Authentication ID token represents an end user. A
Google Cloud access token or attached service account
represents a workload. Firestore mobile/web SDK calls are
authorized by Security Rules using request.auth;
Admin/server libraries bypass those Rules and are authorized
with IAM. If a backend receives a user ID token, verifying
that token establishes user identity only—the backend must
still make its own application authorization decision before
using its more powerful workload identity. App Check, when
enabled, is an additional app/device attestation signal and
does not replace either user authorization or IAM.
Firebase CLI 15.30.0 was released 9 September 2026. Firebase
Admin Node.js 14.4.0 was released 10 September 2026, requires
Node.js 22+, and uses
@google-cloud/firestore 9.1.0. The Auth emulator
issues unsigned ID tokens for local testing; Admin SDK accepts
those only when FIREBASE_AUTH_EMULATOR_HOST is
explicitly configured. Never carry that emulator environment
variable into production.
Learning outcomes
Classify each AtlasMart request as client-direct, backend-mediated, or workload-only before choosing an authorization mechanism.
Explain what Firebase Authentication, Security Rules, IAM, ADC, service accounts and application authorization each prove—and what they do not prove.
Demonstrate that a client write denied by Security Rules can still be performed through a trusted Admin/server SDK path.
Distinguish authentication failures, Security Rules denials, IAM denials and application-level authorization failures in logs and tests.
Apply the same trust-boundary reasoning to Standard Native, Enterprise Native Core/Pipeline and MongoDB compatibility without conflating their access surfaces.
2. The principal map: user identity is not workload identity
| Request path | End-user identity | Workload identity | Primary authorization | Can Rules protect this path? |
|---|---|---|---|---|
| Browser/mobile → Firestore | Firebase Auth ID token | None exposed to app | Security Rules (+ App Check if enabled) | Yes; this is the intended Rules path |
| Browser/mobile → AtlasMart API → Firestore | Firebase Auth ID token verified by API | API service account / ADC | Application authorization, then IAM for Firestore | Rules do not constrain the API Firestore call |
| Background worker → Firestore | Usually no end-user token | Worker service account / workload identity | IAM + worker business policy | No |
| Admin script → Firestore | Operator identity may obtain ADC | User ADC or impersonated service account | IAM + operational procedure | No |
| MongoDB-compatible driver → Enterprise DB | Not the Firebase mobile/web Rules path | SCRAM user or supported Google/OIDC workload identity | MongoDB-compatibility authentication + IAM/DB permissions | Do not assume Native client Rules model |
Firebase Auth answers “which signed-in user is this?” for
client-oriented flows. IAM answers “which Google Cloud principal
is this workload operating as?” for privileged server flows.
Security Rules can use request.auth.uid and token
claims, but an Admin/server Firestore call is not converted into
a Rules request just because the backend originally received a
Firebase ID token.
If support user u-support-1 calls the AtlasMart
API, the API first verifies the user token and decides whether
that user may refund the order. The subsequent Firestore write
is performed as the API workload identity. IAM can permit that
workload to write; only the application authorization layer
knows that this particular support user is allowed to refund
this particular tenant/order.
3. Build the local trust-boundary lab
{ "name": "atlasmart-firestore-ch14", "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-ch14 && cd atlasmart-firestore-ch14npm 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 snippets 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 tenant() { return signedIn() ? request.auth.token.tenantId : null; } 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 /orders/{orderId} { allow get: if signedIn() && resource.data.tenantId == tenant() && resource.data.customerId == request.auth.uid; allow list: if signedIn() && resource.data.tenantId == tenant() && resource.data.customerId == request.auth.uid && request.query.limit <= 25; allow create: if signedIn() && request.resource.data.tenantId == tenant() && request.resource.data.customerId == request.auth.uid && request.resource.data.status == "DRAFT"; allow update, delete: if false; } match /refundRequests/{requestId} { // Clients can create a request; only trusted backend code changes status. allow create: if signedIn() && request.resource.data.requestedBy == request.auth.uid && request.resource.data.status == "REQUESTED"; allow get: if signedIn() && resource.data.requestedBy == request.auth.uid; allow list, update, delete: if false; } match /{document=**} { allow read, write: if false; } }}
These Rules deliberately deny client-side updates of
orders.status. That turns the refund operation into
a privileged server action. The rule is not a “backend rule”; it
is specifically the mobile/web policy boundary.
import fs from "node:fs";import test, { before, after, beforeEach } from "node:test";import { initializeTestEnvironment, assertSucceeds, assertFails} from "@firebase/rules-unit-testing";import { doc, getDoc, setDoc, updateDoc } 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 ctx => { const db = ctx.firestore(); await setDoc(doc(db, "profiles/u-1001"), { uid: "u-1001", tenantId: "t-atlas", displayName: "Ava", locale: "en" }); await setDoc(doc(db, "orders/o-9001"), { tenantId: "t-atlas", customerId: "u-1001", status: "PAID", totalCents: 12990 }); });});after(async () => env.cleanup());test("client path is constrained by Rules", async () => { const ava = env.authenticatedContext("u-1001", { tenantId: "t-atlas", role: "customer" }).firestore(); const other = env.authenticatedContext("u-2002", { tenantId: "t-atlas", role: "customer" }).firestore(); await assertSucceeds(getDoc(doc(ava, "orders/o-9001"))); await assertFails(getDoc(doc(other, "orders/o-9001"))); await assertFails(updateDoc(doc(ava, "orders/o-9001"), { status: "REFUNDED" }));});
// Never ship this module to a browser bundle.process.env.FIRESTORE_EMULATOR_HOST = "127.0.0.1:8080";process.env.FIREBASE_AUTH_EMULATOR_HOST = "127.0.0.1:9099";process.env.GCLOUD_PROJECT = "demo-atlasmart-firestore";import { initializeApp } from "firebase-admin/app";import { getAuth } from "firebase-admin/auth";import { getFirestore } from "firebase-admin/firestore";initializeApp({ projectId: "demo-atlasmart-firestore" });export const adminAuth = getAuth();export const adminDb = getFirestore();
import test from "node:test";import assert from "node:assert/strict";import "./backend/admin.js";import { adminDb } from "./backend/admin.js";test("Admin SDK can perform a write forbidden to client Rules", async () => { const ref = adminDb.doc("orders/o-9001"); await ref.set({ tenantId: "t-atlas", customerId: "u-1001", status: "PAID", totalCents: 12990 }); await ref.update({ status: "REFUNDED" }); const snap = await ref.get(); assert.equal(snap.data().status, "REFUNDED");});// What this proves: the privileged SDK path is not governed by firestore.rules.// What it does NOT prove: production IAM is correct; the emulator does not enforce IAM.
Expected evidence: the owning client can read its own order,
another client cannot, and the owning client still cannot mutate
status. The Admin path can mutate
status despite that client prohibition. This is the
local proof that server libraries bypass Security Rules.
4. Authentication failure, rule denial, application denial and IAM denial are different failures
| Failure | Where it occurs | Typical evidence | Repair direction |
|---|---|---|---|
| No/invalid Firebase ID token | Client or backend identity verification | Unauthenticated / token verification error | Fix sign-in/token transport; do not weaken authorization |
| Security Rules denial | Mobile/web Firestore path | PERMISSION_DENIED with emulator rule trace | Fix query/write shape or Rules if policy is wrong |
| Application authorization denial | AtlasMart API after token verification | 403 with structured reason such as ROLE_REQUIRED | Fix role/tenant/resource policy; IAM may be perfectly healthy |
| IAM denial | Privileged server call in production | PERMISSION_DENIED attributed to workload principal | Grant the minimum required permission/role to the correct principal |
| App Check rejection | Supported production Firebase service enforcement | Invalid/missing attestation telemetry | Fix app attestation/integration; do not use App Check as user authorization |
A single “403” dashboard metric is therefore inadequate. AtlasMart should preserve the layer and reason in structured logs so an incident responder can distinguish a malicious user, a rule regression, a missing IAM binding and a broken App Check rollout.
5. Wrong approach: “The user is authenticated, therefore the request is authorized”
A UI might hide the refund button unless
role === 'support'. An attacker can still call the
endpoint directly. Likewise, a backend might verify an ID token
and immediately perform an Admin SDK update. Both approaches
authenticate the caller but fail to authorize the requested
business action.
export async function verifiedActor(adminAuth, authorizationHeader) { const prefix = "Bearer "; if (!authorizationHeader?.startsWith(prefix)) { throw Object.assign(new Error("UNAUTHENTICATED"), { status: 401 }); } const idToken = authorizationHeader.slice(prefix.length); const decoded = await adminAuth.verifyIdToken(idToken); return { uid: decoded.uid, tenantId: decoded.tenantId, role: decoded.role, authTime: decoded.auth_time };}export function authorizeRefund(actor, order) { if (actor.tenantId !== order.tenantId) { throw Object.assign(new Error("CROSS_TENANT"), { status: 403 }); } if (!["support", "admin"].includes(actor.role)) { throw Object.assign(new Error("ROLE_REQUIRED"), { status: 403 }); } if (order.status !== "PAID") { throw Object.assign(new Error("ORDER_NOT_REFUNDABLE"), { status: 409 }); }}
export function auditAuthorization({ correlationId, actor, action, resource, outcome, reason }) { console.log(JSON.stringify({ event: "authorization_decision", correlationId, actorUid: actor?.uid ?? null, actorTenantId: actor?.tenantId ?? null, actorRole: actor?.role ?? null, action, resource, outcome, reason, // Deliberately omitted: ID token, refresh token, Authorization header, // service-account private keys, ADC credential material. at: new Date().toISOString() }));}
The repair is explicit: verify token → normalize actor → load the minimum authoritative resource → evaluate tenant/role/invariant policy → perform privileged write → record a non-secret authorization decision. Do not log raw bearer tokens to make debugging easier; that turns observability into credential leakage.
6. Edition and mode boundaries
| Database surface | Client-direct authorization | Privileged/server authorization | Chapter 14 consequence |
|---|---|---|---|
| Standard Native Core | Firebase Auth + Security Rules | ADC/IAM | Canonical lab |
| Enterprise Native Core | Firebase Auth + Security Rules | ADC/IAM | Same identity split; re-check feature/SDK availability |
| Enterprise Native Pipeline | Security Rules still govern supported client Pipeline operations; rule-query proof differs | ADC/IAM for server libraries |
Do not assume Core request.query semantics
|
| Enterprise MongoDB compatibility | Separate MongoDB-compatible connection/authentication model | IAM, supported workload OIDC/service accounts, or database user credentials depending on connection path | Do not paste Native Security Rules assumptions onto a MongoDB driver |
The trust principle survives the product variation: never let a privileged workload credential become an end-user credential, and never assume an end-user token restricts the backend’s Firestore identity automatically.
Verification checklist and cleanup
- Client owner read succeeds; cross-user read fails.
- Client order-status update fails under Rules.
- Admin SDK emulator update succeeds, proving Rules bypass only—not production IAM correctness.
- Application authorization returns a separate 403 reason before privileged write for wrong tenant/role.
- No log line contains a raw JWT, refresh token, service-account key, Authorization header or ADC file.
- Stop emulators to discard state, or restart with a clean data directory before the next exercise.
Production judgment and bridge to Lesson 2
Choose the authorization layer from the request path, not from the collection name. Direct client access is attractive when Rules can express the policy safely. Privileged or cross-resource actions often belong behind a backend, but moving them there increases blast radius because that backend is now an IAM principal with broader database capability. Lesson 2 focuses on containing that blast radius with least-privilege service identities plus mandatory application-layer authorization.
Knowledge check
- Does verifying a Firebase ID token make an Admin SDK Firestore write obey Security Rules?
- What does the emulator Admin-bypass test prove?
- Why is UI button visibility not authorization?
- Which identity does IAM evaluate for a backend Firestore call?
- Is MongoDB compatibility simply Native mode with different query syntax?
Review the answers
1. No. Token verification identifies the user; the Admin/server Firestore call uses the backend workload identity and bypasses Rules.
2. That the server SDK path is not constrained by the loaded Firestore Rules. It does not prove production IAM is correct.
3. Attackers can call APIs directly; authorization must be enforced at the policy boundary that performs the action.
4. The workload principal represented by ADC/service account or equivalent credentials.
5. No. It has a distinct driver/authentication surface and must not inherit Native client Rules assumptions automatically.
Summary
AtlasMart now has an explicit principal map. End users authenticate with Firebase Auth, direct client requests meet Security Rules, privileged workloads meet IAM, and backend business authorization bridges the two identities without confusing them.
Authoritative references
- Secure data in Cloud Firestore
- Firestore IAM
- Security Rules conditions and server-library bypass
- Add the Firebase Admin SDK to your server
- Verify Firebase ID tokens
- Custom claims and access control
- Manage user sessions and token revocation
- Connect to the Authentication emulator
- Connect to the Firestore emulator
- How Application Default Credentials works
- Best practices for service accounts
- Workload Identity Federation
- Firestore IAM roles and permissions
- Authenticate and connect with Firestore MongoDB compatibility
- Enterprise Native server client libraries
- Firebase Admin Node.js SDK release notes
- Firebase CLI release notes