Chapter 14 · IAM, Admin/Server SDKs, Service Accounts, Authentication, and Trust Boundaries
Design Separate Browser / Mobile and Backend Access Paths with Explicit Trust, Audit, and Failure Boundaries
Combine browser/mobile, backend and workload-only paths into one AtlasMart trust architecture with independent authorization, auditing, failure injection and recovery boundaries.
1. Design the complete access path, not isolated auth snippets
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 14 culminates in an AtlasMart design where each path has one explicit principal, one authorization boundary, one observability contract and one failure mode. The browser can read owned data directly under Rules. Privileged refund actions go through a backend that verifies the user token, applies business authorization, and writes as a least-privileged workload. Background repair jobs have no fake user identity. None of those paths leaks privileged credentials to clients.
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.
Learning outcomes
Draw end-to-end trust paths for browser/mobile, user-driven backend and workload-only operations.
Preserve both actor identity and workload identity in audit evidence without logging secrets.
Test direct-client Rules denials separately from backend application denials and production IAM denials.
Design failure containment for stale claims, revoked sessions, IAM changes and compromised service identities.
Choose when direct Firestore access is appropriate and when a backend boundary is required.
2. AtlasMart target architecture
| 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 |
[Browser / Mobile] | Firebase Auth ID token + optional App Check |---- direct Firestore read/write ----> [Security Rules] ---> [Firestore] | | HTTPS Bearer ID token v[AtlasMart Orders API] 1. verify user token 2. application authorization (tenant/role/resource/invariant) 3. idempotency / transaction policy where needed | ADC / attached or federated workload identity v[IAM] ---> [Firestore][Background Worker] | workload identity only v[IAM] ---> [Firestore]Never: Browser ---> service-account key ---> Firestore
3. Direct client path: ownership belongs in Rules
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; } }}
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" }));});
The direct path is appropriate when the authorization policy is naturally document/query-scoped and the client can safely hold only end-user credentials. It also supports realtime/offline behavior taught earlier. Do not route everything through a backend merely to avoid learning Rules; equally, do not force privileged cross-resource business workflows into client Rules when a trusted service boundary is clearer.
4. Backend path: user intent enters a stronger principal
// 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();
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 async function handleRefundRequest(req) { const correlationId = req.headers['x-correlation-id'] ?? crypto.randomUUID(); const actor = await verifiedActor(adminAuth, req.headers.authorization); const orderRef = adminDb.doc(`orders/${req.body.orderId}`); const orderSnap = await orderRef.get(); if (!orderSnap.exists) return { status: 404 }; authorizeRefund(actor, orderSnap.data()); // Chapter 10 principle: external payment action needs durable idempotency/workflow state. // Chapter 9 principle: state-dependent Firestore changes use transaction/precondition as needed. await orderRef.update({ status: 'REFUND_PENDING' }); auditAuthorization({ correlationId, actor, action: 'order.refund', resource: orderRef.path, outcome: 'allow', reason: 'policy_passed' }); return { status: 202, correlationId };}
This code intentionally links Chapter 14 to Chapters 9–10: authorization does not make retries/idempotency disappear. A trusted backend is more privileged, not magically atomic.
5. Background path: do not invent a fake user
A reconciliation worker that scans projection records is a
workload-only actor. Giving it a hard-coded
uid='admin' creates misleading audit semantics. It
should run as a distinct service identity, receive only the
Firestore permissions it needs, and write operational records
with a workload/correlation identifier rather than pretending a
human requested each repair.
{ "event": "projection_repair", "correlationId": "job-2026-09-16T18:00Z-17", "workload": "atlasmart-projection-repair", "actorUid": null, "source": "scheduled-reconciliation", "resource": "users/u-1001/feed/e-order-o-9003", "outcome": "repaired"}
6. Failure-boundary matrix
| Failure injected | Correct boundary | Expected observable result | Unsafe reaction to avoid |
|---|---|---|---|
| Expired/invalid user ID token | Backend token verification | 401 before Firestore privileged action | Do not retry as Admin without user context |
| Valid token, wrong role | Application authorization | 403 ROLE_REQUIRED | Do not “fix” by granting broader IAM |
| Valid support, wrong tenant | Application authorization | 403 CROSS_TENANT | Do not trust client-supplied tenant alone |
| Rules regression on direct client query | Security Rules tests | CI failure / emulator denial | Do not move query to Admin proxy as shortcut |
| Workload IAM role removed | IAM production boundary | Privileged Firestore call denied | Do not grant Owner/Editor to make error disappear |
| Claims updated but old token reused | Token lifecycle | Old claim remains until refresh/reissue | Do not assume user record mutation rewrites JWTs |
| Refresh tokens revoked | Auth session policy | Revocation-aware backend check denies | Do not log/replay token to debug |
| Orders API credential compromised | Workload identity boundary | Disable/revoke identity and inspect audit trail | Do not share same identity with unrelated workers |
7. Audit model: reconstruct both “who asked” and “who executed”
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() }));}
For user-driven backend operations, application logs should include a correlation ID and normalized user claims while Cloud Audit Logs can provide production workload principal evidence. For direct mobile/web Firestore requests, Rules outcomes and application telemetry describe the user-side path. Do not make one log stream pretend to cover both trust planes.
8. Security test suite: prove paths independently
const cases = [ { name: 'owner-direct-read', path: 'client', expect: 'ALLOW' }, { name: 'other-user-direct-read', path: 'client', expect: 'RULES_DENY' }, { name: 'client-refund-write', path: 'client', expect: 'RULES_DENY' }, { name: 'customer-refund-api', path: 'backend', expect: 'APP_DENY_ROLE' }, { name: 'support-cross-tenant-refund', path: 'backend', expect: 'APP_DENY_TENANT' }, { name: 'support-same-tenant-refund', path: 'backend', expect: 'APP_ALLOW_LOCAL' }, { name: 'backend-without-cloud-role', path: 'production-only', expect: 'IAM_DENY' }];for (const c of cases) console.log(JSON.stringify(c));// Local suite proves client Rules + backend app authorization + Admin bypass.// IAM_DENY requires an isolated real Google Cloud project and is never faked.
9. Operational runbook and credential handling
| Event | Immediate action | Follow-up evidence |
|---|---|---|
| Support role granted/removed | Update claims; trigger/await token refresh according to UX/risk | Decoded new token claims, no raw JWT |
| User session suspected stolen | Revoke refresh tokens; require reauthentication where policy demands | tokensValidAfter/revocation-aware verification result |
| Service identity suspected compromised | Disable/restrict workload identity or federation trust; rotate key only if a key exists | IAM/Audit history + service logs |
| IAM binding accidentally removed | Restore minimum required role to correct principal | Permission test and policy diff |
| Rules accidentally broadened | Rollback/deploy tested ruleset | Emulator CI + production deployment revision |
| Credential accidentally committed | Revoke immediately, remove from history as appropriate, rotate dependent secrets | Secret scanning + IAM/auth audit |
Production judgment: when should AtlasMart use which path?
| Need | Preferred path | Reason |
|---|---|---|
| Own profile/order reads, realtime/offline UI | Direct client + Rules | Fine-grained user authorization at document/query boundary; client-friendly realtime/cache |
| Privileged refund, admin state transition | Backend + verified ID token + app authz + IAM | Business policy and privileged write belong in trusted code |
| Projection repair / scheduled reconciliation | Workload-only backend + IAM | No end-user principal exists; keep audit honest |
| Complex policy changing faster than claims | Backend policy lookup | Avoid stale token-only authorization |
| MongoDB driver workload | MongoDB compatibility connection/auth model | Native mobile/web Rules diagram is not the right interface |
There is no universal “backend is more secure” rule. A poorly authorized Admin proxy is often less secure than direct client access guarded by well-tested Rules. Choose the path whose authorization model can be made explicit, testable and least-privileged.
10. Bridge to Chapter 15
With trust boundaries explicit, the next question is physical scaling. Chapter 15 moves from “who is allowed to write?” to “what happens when many allowed writers hit the same documents, key ranges and indexes?” It covers document contention, sequential IDs/fields, index fan-out, gradual traffic ramp-up and realistic tail-latency measurement.
Knowledge check
- Why should a background worker not use a fake admin UID?
- What two identities should a user-driven backend preserve?
- What should happen if IAM is removed from an otherwise authorized backend?
- Is routing every client request through Admin SDK automatically safer?
- What does the local Chapter 14 suite deliberately not claim to test?
Review the answers
1. Its true principal is the workload identity; inventing a user obscures authorization and audit semantics.
2. The verified end-user actor and the backend workload executor.
3. The Firestore call should fail at IAM; do not compensate by broadening application policy or granting primitive roles.
4. No. A generic or weakly authorized privileged proxy can expand blast radius beyond well-tested direct Security Rules.
5. Production IAM, Cloud Audit Logs, App Check enforcement, regional behavior or real credential infrastructure.
Summary
AtlasMart now has explicit trust, audit and failure boundaries for direct clients, user-driven backends and workload-only jobs. End-user tokens never become workload credentials, workload credentials never ship to clients, and every privileged action is authorized twice in the right places: business policy in application code and resource capability in IAM.
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