Chapter 14 · IAM, Admin/Server SDKs, Service Accounts, Authentication, and Trust Boundaries
Admin / Server SDKs Bypass Security Rules: Service Account Least Privilege and Application-Layer Authorization
Contain the power of Admin/server SDKs with least-privilege workload identities and explicit AtlasMart application authorization instead of relying on client Security Rules.
1. The dangerous misconception: “Admin means trusted, therefore safe”
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.
Admin/server libraries are trusted by Firestore in the narrow
sense that Security Rules are not evaluated. That is not
evidence that every caller of AtlasMart’s backend deserves every
database action. If the orders API can update any order and the
API simply forwards req.body.path and
req.body.data, a single application authorization
bug converts the service account’s IAM scope into an attacker’s
write scope.
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
Explain precisely why Admin/server Firestore calls bypass Security Rules and where IAM takes over.
Choose a workload identity and predefined Firestore IAM role from required operations instead of granting Owner/Editor by convenience.
Implement a privileged AtlasMart refund operation that verifies end-user identity and applies tenant/role/invariant checks before the Admin write.
Keep the local emulator proof distinct from production IAM proof.
Model blast radius when one service identity is reused across unrelated services.
2. Least privilege has two dimensions
Cloud least privilege limits what the workload
principal can do to Google Cloud resources.
Application least privilege limits what a
verified user may ask that workload to do. Firestore IAM roles
operate at resource/API permission scope; they do not encode
AtlasMart’s customerId, tenant refund policy, order
state machine, or fraud review.
| Control | Question | Example |
|---|---|---|
| IAM | May this workload read/write Firestore at all? | Orders API gets a Firestore data role; reporting worker may be read-only |
| Application authorization | May this actor perform this business action on this resource? | Support role + same tenant + PAID order + amount within policy |
| Security Rules | May a mobile/web client perform this direct Firestore request? | Customer can read own order, cannot set REFUNDED |
| Network/runtime policy | Where can workload credentials be used from? | Attached identity/WIF, egress controls, service boundary |
| Audit | Can we reconstruct who requested and which workload executed? | Correlation ID + actor UID + workload/service + resource + outcome |
3. Build the privileged refund endpoint as a narrow capability
// 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 }); }}
import { adminAuth, adminDb } from "./admin.js";import { verifiedActor, authorizeRefund } from "./authorize.js";import { auditAuthorization } from "./audit.js";export async function refundOrder({ authorization, orderId, correlationId }) { const actor = await verifiedActor(adminAuth, authorization); const ref = adminDb.doc(`orders/${orderId}`); const snap = await ref.get(); if (!snap.exists) throw Object.assign(new Error("NOT_FOUND"), { status: 404 }); const order = snap.data(); try { authorizeRefund(actor, order); auditAuthorization({ correlationId, actor, action: "order.refund", resource: ref.path, outcome: "allow", reason: "policy_passed" }); } catch (error) { auditAuthorization({ correlationId, actor, action: "order.refund", resource: ref.path, outcome: "deny", reason: error.message }); throw error; } // Chapter 9/10 invariants still apply: use transaction/precondition/idempotency // if concurrent state or external payment side effects are involved. await ref.update({ status: "REFUNDED" }); return { orderId, status: "REFUNDED" };}
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() }));}
This endpoint exposes one business operation rather than a generic Firestore proxy. The service account may technically write many documents, but the code narrows what users can ask it to do. That separation also makes authorization tests readable.
4. Deliberate failure: a generic Admin proxy defeats your Rules design
// DANGEROUS: verified identity is not enough.export async function updateAnything(req) { const actor = await verifiedActor(adminAuth, req.headers.authorization); console.log("authenticated", actor.uid); await adminDb.doc(req.body.path).update(req.body.patch);}
An ordinary customer could request
orders/o-9001 with
{"status":"REFUNDED"}. Security Rules cannot save
this code because the Admin write bypasses them. The safe repair
is to expose purpose-specific commands such as
refundOrder, derive target paths from validated
identifiers, and authorize against authoritative server data.
5. IAM roles are capabilities, not business roles
| IAM choice | Appropriate use | Why not more? |
|---|---|---|
| roles/datastore.viewer | Read-only Firestore workloads | No data mutation permission |
| roles/datastore.user | Application workloads that need normal entity/document read/write | Avoid Owner/Editor; still broader than per-document business policy |
| Index/admin-specific roles | Deployment/operations jobs that manage indexes or administration | Do not give runtime APIs schema/admin permissions they never use |
| Owner/Editor | Rare administration/bootstrap contexts | Primitive roles create a much larger blast radius and are poor runtime defaults |
PROJECT_ID="YOUR_ISOLATED_PROJECT"SA="atlasmart-orders-api@${PROJECT_ID}.iam.gserviceaccount.com"# Create one workload identity for one service responsibility.gcloud iam service-accounts create atlasmart-orders-api --project="$PROJECT_ID" --display-name="AtlasMart orders API"# roles/datastore.user permits Firestore entity read/write operations.# It is still broader than per-customer authorization; application code must enforce that.gcloud projects add-iam-policy-binding "$PROJECT_ID" --member="serviceAccount:${SA}" --role="roles/datastore.user"# Inspect exactly what the principal has before deployment.gcloud projects get-iam-policy "$PROJECT_ID" --flatten="bindings[].members" --filter="bindings.members:serviceAccount:${SA}" --format="table(bindings.role)"
The mandatory emulator lab cannot verify IAM because the Firestore emulator does not enforce Google Cloud IAM. Run IAM commands only in an isolated billed project you control. Record the workload principal, role binding, database, region and cleanup commands; do not present emulator success as IAM evidence.
6. One workload identity per responsibility limits blast radius
Suppose AtlasMart deploys an orders API, a reporting job and an index-deployment job. Reusing one highly privileged service account means compromise of the reporting job can mutate orders and manage indexes. Separating identities permits a read-only reporting principal and keeps operational roles away from request-serving workloads. The design objective is not “many service accounts”; it is independent revocation and minimum permissions aligned with service responsibilities.
| Service | User-facing? | Suggested capability shape | Audit value |
|---|---|---|---|
| orders-api | Yes | Data read/write + strict app authz | Correlate actor UID with workload principal |
| reporting-worker | No | Read-only if possible | Unexpected writes become impossible by IAM |
| index-deployer | No | Index administration only | Schema change attributable to CI/deployer identity |
| local developer | No | User ADC or impersonation; emulator when possible | Avoid persistent downloaded runtime keys |
7. Observability: preserve two actors
A backend log should preserve the end-user actor and the workload executor as separate concepts. Cloud Audit Logs can identify the Google Cloud principal for supported production actions; AtlasMart application logs must add the verified user UID/tenant/role and the business reason. Never put raw credentials in either.
{ "event": "authorization_decision", "correlationId": "corr-7f8c", "actorUid": "u-support-1", "actorTenantId": "t-atlas", "actorRole": "support", "workload": "atlasmart-orders-api", "action": "order.refund", "resource": "orders/o-9001", "outcome": "allow", "reason": "policy_passed"}
8. Verification matrix
| Case | Expected app decision | Expected Rules relevance | Expected production IAM relevance |
|---|---|---|---|
| Customer directly updates status | Denied | Yes: Rules deny | None; no backend call |
| Customer calls refund endpoint | Denied ROLE_REQUIRED | No Rules on Admin write because write never occurs | Workload IAM is irrelevant to the user denial |
| Support from wrong tenant | Denied CROSS_TENANT | No | IAM could still allow workload; app must deny |
| Authorized support same tenant | Allowed if order invariant passes | No on backend write | Workload needs data write permission |
| Workload role removed | App policy may allow, Firestore call fails | No | Yes: IAM denies |
Production judgment and bridge to Lesson 3
IAM limits what a service can technically do; application authorization limits what a caller can cause it to do. Neither replaces the other. Keep service identities narrow, avoid primitive roles, and design privileged APIs as business capabilities rather than generic database tunnels. Lesson 3 now examines the user-side role signal—custom claims—and why token refresh and revocation make them unsuitable as an instant, rapidly changing authorization database.
Knowledge check
- Why can a customer exploit a generic Admin proxy even if Firestore Rules are perfect?
- What is the difference between roles/datastore.user and an AtlasMart support role?
- Why is one service account for every backend a blast-radius problem?
- Can the emulator prove a production IAM binding?
- What should privileged APIs expose instead of arbitrary document paths?
Review the answers
1. Because server/Admin Firestore requests bypass Security Rules; the proxy must enforce application authorization itself.
2. The IAM role gives the workload Firestore API capability; the support role is an application policy attribute for an end user.
3. Compromise of any one service inherits all permissions granted to that shared identity.
4. No. Emulator tests can prove application logic and Rules behavior, not Google Cloud IAM enforcement.
5. Purpose-specific business operations with validated identifiers, authoritative reads and explicit authorization checks.
Summary
The Admin SDK is powerful because it bypasses client Rules. That power is safe only when the workload principal is least-privileged and every user-driven privileged operation has an independent application authorization decision.
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