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.

Intermediate → Advanced170–200 minutesArchitecture · audit · failure boundariesFirebase JS 12.19.0 · Admin 14.4.0 · CLI 15.30.0 · Rules tests 5.0.2Last reviewed: September 2026

1. Design the complete access path, not isolated auth snippets

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

Chapter 14 reproducibility baseline · reviewed 16 September 2026

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.

Trust boundary that must not be blurred

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

01

Draw end-to-end trust paths for browser/mobile, user-driven backend and workload-only operations.

02

Preserve both actor identity and workload identity in audit evidence without logging secrets.

03

Test direct-client Rules denials separately from backend application denials and production IAM denials.

04

Design failure containment for stale claims, revoked sessions, IAM changes and compromised service identities.

05

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
trust-flow.txt
[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

firestore.rules · client ownership boundary
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;    }  }}
client-path.test.mjs · Security Rules are evaluated
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

backend/admin.js · emulator-safe initialization
// 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();
backend/authorize.js · identity then application authorization
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 });  }}
refund orchestration pseudocode
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.

worker audit record
{  "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”

backend/audit.js · log decisions, never credentials
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

trust-boundary.contract.mjs
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

  1. Why should a background worker not use a fake admin UID?
  2. What two identities should a user-driven backend preserve?
  3. What should happen if IAM is removed from an otherwise authorized backend?
  4. Is routing every client request through Admin SDK automatically safer?
  5. 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

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.