Least privilege is a habit, not a config file: GCP and Firebase IAM done right with blast radius thinking, scoped service accounts, and Firestore rules.
A client once asked me to "just make the deploy work" before a demo. Fifteen minutes later there was a service account with Editor on the entire GCP project, its JSON key pasted into a Slack thread, and a green checkmark on the pipeline. It worked. It also stayed exactly like that for eight months, because nobody ever came back to tighten it — including me, and I'm the one who's supposed to care about this stuff. That's the real shape of least privilege on a small team: not a wrong decision, but a right-enough decision that nobody revisits.
Least privilege gets sold as a setting you turn on. It isn't. It's a habit — the ongoing discipline of granting the narrowest access that unblocks the task, and then actually clawing it back when the task is done. The config file is just where the habit leaves its fingerprints. If you treat it as a one-time hardening pass, you will drift, and you'll drift in exactly one direction: toward everyone and everything being an admin.
I've shipped production Flutter and Firebase apps for six years, most of them on teams small enough that "the security team" is whoever last touched IAM. Everything below is the access-control discipline that actually survives that environment — GCP IAM, Firebase Security Rules, service accounts, and offboarding — written for people who have to ship this week, not just harden in the abstract.
The drift isn't laziness. It's the sum of a hundred reasonable-in-the-moment decisions.
You're three people. Someone's blocked on a Friday because they can't read a Firestore collection. Do you (a) carefully scope a custom IAM role, or (b) grant them Editor and get back to shipping? On a small team the honest answer is almost always (b), and the honest reason is that the cost of over-granting feels like zero. Nothing breaks when you give someone too much. Things break when you give them too little.
That asymmetry is the whole problem with access control on a fast team. Over-provisioning has no immediate feedback. The pain is deferred — it shows up months later as a leaked credential, an ex-contractor who can still nuke prod, or a compromised laptop that happened to hold Owner. So the natural gradient of a fast team points straight at maximum privilege.
The fix isn't to become slow and bureaucratic. It's to move the feedback earlier — to make "how wide is this?" a reflex you hit before granting, and to make revocation cheap enough that you actually do it. Everything else in this post is a variation on those two moves: cheaper questions up front, cheaper cleanup later.
Before I grant any access — a role, a key, a Firestore rule — I ask one question: if this identity is fully compromised tomorrow, what's the worst it can do?
That's the blast radius. Not "will this person misuse it" — assume they won't. Assume instead that the credential leaks, because credentials leak. The question is what an attacker holding it can reach.
I run it as a quick mental checklist:
Anything that scores high on blast radius gets the slow, careful treatment: a narrow custom role, a short-lived credential, a second pair of eyes. Anything that scores low — read access to a logs bucket, say — I grant without ceremony. The point isn't to make everything hard. It's to spend your carefulness where the blast radius actually justifies it. Most access is low-stakes; a small slice is catastrophic. Least privilege is knowing which slice you're touching.
A concrete way to feel the difference: roles/storage.objectViewer on one bucket and roles/owner on the project are both "one grant" in the UI, one click apart. But one leaks a folder of images and the other hands over your database, your billing, and the ability to lock you out of your own project. The console makes them look equally cheap. The blast-radius question is what restores the price tag.
Humans get offboarded. Service accounts just accumulate. They're the identities nobody remembers creating, they never leave, and they usually hold more power than any single human on the team. On GCP and Firebase they're where least privilege quietly dies.
The classic anti-pattern is what I did in that opening story: one service account, Editor on the whole project, key exported to CI. If that key leaks, the attacker has your entire backend.
Three habits keep service accounts honest.
Your CI deployer, your image-processing worker, and your backup script are three different identities with three different narrow roles — not one god account shared across all of them. When something misbehaves, you want to revoke that job, not take down everything. This also makes your audit logs legible: when a principal named image-worker@ shows up reading your billing data, the anomaly is obvious in a way it never is when everything runs as one firebase-adminsdk@ account doing a bit of everything.
Exported JSON keys are the single worst thing in this whole space, because they're long-lived, copy-pasteable, and invisible once leaked. On GCP, Workload Identity Federation lets your CI authenticate without a downloaded key — the runner proves its identity with a short-lived OIDC token instead. If you're on GitHub Actions, you can wire it up like this:
# .github/workflows/deploy.ymlpermissions: contents: read id-token: write # lets the runner mint a short-lived OIDC tokenjobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - id: auth uses: google-github-actions/auth@v2 with: workload_identity_provider: projects/123456789/locations/global/workloadIdentityPools/github/providers/my-repo service_account: ci-deployer@my-project.iam.gserviceaccount.com # no JSON key anywhere — the runner gets a token that expires in minutes
There is no file to leak. The credential lives for the length of the job and then it's gone. That single change removes an entire category of "our key ended up in a public repo" incidents — the kind automated secret scanners find in minutes and attackers find faster. If you genuinely can't avoid a key (some legacy tool, some CI that predates federation), treat it as radioactive: scope its service account to the single task, rotate it on a schedule, and never let it near a chat log.
roles/editor is not a security decision, it's a shrug. If your deployer only deploys Cloud Run and reads one bucket, give it roles/run.developer and roles/storage.objectViewer on that bucket, and nothing else. It takes ten extra minutes. It's the difference between a leaked CI token being an annoyance and being a company-ending event.
When you're not sure which predefined role is narrow enough, GCP's IAM recommender and the "permission required" hints on API errors are usually enough to find one. If no predefined role fits without dragging in extra permissions, build a custom role with exactly the permissions the job calls — a custom role scoped to six permissions beats Editor's thousands every single time.
A quick command I run periodically to see who's holding the keys to the kingdom:
# Which principals hold broad, dangerous roles on the project?gcloud projects get-iam-policy my-project \ --flatten="bindings[].members" \ --filter="bindings.role:(roles/owner OR roles/editor)" \ --format="table(bindings.role, bindings.members)"
Every line that comes back is a question you should be able to answer. If you can't, that's your next cleanup.
It's easy to file "leaked service account key" under abstract risk. It isn't. Walk the eight-month Editor key from my opening story through what an attacker actually does with it, and the abstraction disappears fast.
With Editor on the project, the holder can read every Firestore document and every Storage bucket — that's your entire user table, exported in an afternoon. They can spin up compute in your billing account and mine crypto until your card maxes out; I've watched a founder eat a five-figure surprise bill from exactly this, on a project that made no money. They can delete backups. On a bad day they can quietly add themselves a second identity so that rotating the leaked key changes nothing, because the attacker no longer needs it. That last move — persistence through a fresh grant — is why "we rotated the key" is not the same as "we contained the breach."
None of that requires a sophisticated adversary. It requires one key in one Slack export, one laptop with saved credentials stolen at an airport, one npm dependency that reads your environment. The reason to scope the key down to roles/run.developer isn't tidiness. It's that when — not if — one of those things happens, the difference between "we rotated a credential and moved on" and "we're notifying users about a breach" is entirely decided by how wide that key was the day it leaked. You make that decision months in advance, at grant time, when it's cheap. That's the whole game.
Here's a test worth running today: open your IAM page and read every human on the list out loud. On most small teams, at least one name makes you go "wait, are they still around?"
I've been the person who forgot to remove a contractor. We brought someone in for a six-week frontend push, gave them access to move fast, the engagement ended cleanly, everyone said thanks — and four months later I found their Google account still sitting in the project with write access, because offboarding a contractor isn't tied to any HR system that would remind you. Nobody was doing anything wrong. The access just stayed, the way access always does when nobody owns removing it.
For human access, a few rules earn their keep:
engineers@ or deployers@, then manage membership in one place. Offboarding becomes "remove from group" instead of an archaeology dig across five projects — and a new hire gets exactly the right access the moment you add them, no more and no less.The uncomfortable truth is that offboarding is where least privilege is won or lost, and it's the least glamorous part. Granting access feels productive. Removing it feels like paperwork. But the ex-contractor still in your project is the single most common real-world IAM failure I see on small teams — not exotic privilege escalation, just an account that nobody remembered to turn off.
IAM controls who can touch your infrastructure. Firebase Security Rules control who can touch your data — and this is where least privilege stops being an ops concern and becomes something you write in code, per collection, per request. If you serve a mobile or web client directly from Firestore, your security rules are your authorization layer; there is no server in between to save you.
The failure mode here is the mirror image of the god service account. It's the wide-open rule that someone dropped in during a hackathon and never tightened:
// The rule that has ended more startups than any competitorrules_version = '2';service cloud.firestore { match /databases/{database}/documents { match /{document=**} { allow read, write: if true; // anyone on the internet, your whole DB } }}I've reviewed client projects running exactly this in production. It means any person on the internet can read and rewrite your entire database — no auth, no rate limit, no recovery. The "test mode" Firestore offers when you create a project is essentially this rule with a short expiry, and plenty of apps ship before that timer runs out. Least privilege at the data layer means the opposite reflex: every collection describes precisely who may do what.
rules_version = '2';service cloud.firestore { match /databases/{database}/documents { // A user can read and edit only their own profile. match /users/{userId} { allow read: if request.auth != null && request.auth.uid == userId; allow write: if request.auth != null && request.auth.uid == userId && request.resource.data.role == resource.data.role; // can't self-promote } // Orders: the owner can read; nobody writes from the client at all. match /orders/{orderId} { allow read: if request.auth != null && request.auth.uid == resource.data.ownerId; allow write: if false; // writes go through trusted server code only } }}Notice the request.resource.data.role == resource.data.role line — that's least privilege inside a single document. A user may edit their profile, but the fields they can change are constrained, so they can't hand themselves an admin role on the way through. Field-level thinking is the part people skip: "who can write this document" is only half the question; "which fields can they change, to what values" is the other half.
Two habits matter more than any single rule.
Deny by default. In Firestore, anything you don't explicitly allow is denied — lean into that. Don't write a broad allow and then try to carve out exceptions; write narrow allows and let everything else fall through to denied. And be careful with recursive wildcards: a match /{document=**} with a broad allow silently grants everything beneath it, which is exactly how the hackathon rule above does its damage.
Write the negative tests. A rule that lets the right user in is half the job. The half people skip is proving it keeps the wrong user out. The Firebase emulator makes this cheap and runs entirely offline:
import { assertFails, assertSucceeds } from '@firebase/rules-unit-testing';// The test that actually protects you is the one that expects failure.await assertFails( bobDb.collection('users').doc('alice').get() // Bob must NOT read Alice);await assertSucceeds( aliceDb.collection('users').doc('alice').get() // Alice may read Alice);If your rules test suite only has assertSucceeds calls, you haven't tested least privilege — you've tested that the app works. Those are different things. Wire these tests into CI so a well-meaning "just loosen it for now" rule change can't merge without someone seeing the negative cases go red.
Every access system slowly diverges from your mental model of it. You remember the careful grants. You forget the Friday-afternoon Editor. The only cure is to periodically diff what you meant to grant against what's actually there — and the gap is always bigger than you expect.
I keep this deliberately lightweight, because a heavy audit process is an audit process that doesn't happen. Once a quarter, maybe thirty minutes:
gcloud command above). For each, answer: does this still need to exist, at this level?# Every user-managed key is something that can leak. Ideally this is empty.gcloud iam service-accounts keys list \ --iam-account=ci-deployer@my-project.iam.gserviceaccount.com \ --managed-by=user
if true and any allow write you can't immediately justify.The output of an audit is never "all clear." It's a small list of grants that made sense once and don't anymore. That list is the habit working. Write it down where the next person (or the next you) will see it, because an audit whose findings evaporate is just a way to feel productive.
Everything above comes down to one shift: revocation has to be as normal as granting. On most teams, granting is a one-click reflex and revoking is a project nobody schedules. Flip that and the drift stops.
Concretely, that means:
# Access that removes itself. No cleanup ticket required.gcloud projects add-iam-policy-binding my-project \ --member="user:contractor@example.com" \ --role="roles/viewer" \ --condition="expression=request.time < timestamp('2026-08-15T00:00:00Z'),title=contractor-sprint,description=auto-expires"The mindset is expiring credentials over permanent ones, groups over individuals, deny-by-default over allow-and-patch. Least privilege isn't a state you reach and hold. It's a direction you keep pushing against a system that, left alone, always drifts wider.
Editor.admin), and ship with tests that expect failure. allow read, write: if true has ended more startups than any competitor.