GCP Security

GCP IAM and Service Accounts: Roles, Bindings and Least Privilege

Quick take: GCP IAM answers one question on every API call — can this principal do this thing on this resource? The surface is simple: who, what, where. The discipline is hard: use service accounts for workloads, predefined roles for humans, custom roles only when nothing fits, and never download a service-account key when the platform can hand your code a short-lived token instead.

A data team gave every engineer the roles/editor (Project Editor) role so nobody had to file an access ticket. Six months later a misconfigured batch script — running under an engineer’s personal account that had Editor — deleted a production BigQuery dataset. The dataset wasn’t backed up the way they assumed. The cleanup took three days, and the post-mortem found the same broad role on forty other principals. The fix was unglamorous and permanent: a service account scoped to that one job, a predefined role that could read and write tables but not delete datasets, bindings at the dataset level instead of the project level, and a monthly review driven by the IAM Recommender. The blast radius went from “the whole project” to “these two tables.”

That story is the whole article in miniature. Identity and Access Management (IAM) is the layer Google Cloud checks before it lets anyone — a human, a virtual machine, a CI/CD pipeline, a Cloud Run service — touch a resource. This guide is for someone who is new to GCP IAM and wants the real mental model, not a glossary. By the end you will know exactly what a principal, a role, a binding and a policy are; the difference between a user and a service account and when to use each; how a VM or a Cloud Run service gets credentials without any key file; how Workload Identity Federation lets GitHub Actions or AWS authenticate to GCP with no downloaded key at all; and how to apply least privilege with conditions, deny policies and the Recommender. Everything is shown with real gcloud commands you can run.

What problem this solves

Every action in Google Cloud is an API call — storage.objects.get, bigquery.tables.delete, compute.instances.start. IAM is the gate in front of every one of them. Without IAM there is no way to say “analysts can read this dataset but not delete it” or “this VM can write to that bucket but touch nothing else.” The platform would be all-or-nothing, and all-or-nothing is how data gets deleted and credentials get stolen.

The pain shows up in three predictable ways when teams skip the discipline. First, over-broad human access. Handing out roles/editor or roles/owner to avoid tickets means any mistake — a fat-fingered gcloud command, a script with a wrong flag — can damage anything in the project. Second, long-lived service-account keys. A key.json file downloaded for a script is a password that never expires, often lands in a Git repo or a Slack message, and grants whatever that service account can do until someone notices. Leaked GCP keys are one of the most common cloud breach vectors. Third, permission drift. Custom roles and project-level bindings accumulate over years; nobody remembers why some-service-account@ has roles/owner, and nobody dares remove it.

Who hits this: essentially everyone running on GCP, but it bites hardest on small teams who optimise for “make it work today” (and grant Editor to everyone), on anyone deploying workloads that call other Google APIs (VMs, GKE pods, Cloud Run, Cloud Functions — all of which need an identity), and on anyone integrating an external system (GitHub Actions, GitLab, an on-prem job, another cloud) that needs to authenticate into GCP. The fix is never “lock it down so hard nobody can work.” It is: give each principal exactly the permissions its job needs, at the smallest scope that makes sense, with credentials that expire on their own.

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should have a Google Cloud account and a project you can experiment in, the gcloud CLI installed (or use Cloud Shell, which has it pre-installed and pre-authenticated), and the ability to run a command and read its output. You do not need to know any GCP service in depth — we explain each one as it appears. Basic familiarity with the idea of “an API call” and “a JSON document” helps but isn’t required.

IAM sits underneath everything else on GCP. Before you can sensibly grant access you need to understand the GCP Resource Hierarchy Explained: Organization, Folders, Projects, and Why It Runs Everything — because IAM policies attach to nodes in that hierarchy and flow downward. This article is the natural next step after Google Cloud IAM Explained Simply: Members, Roles, and Bindings (Without the Jargon): that piece is the gentle on-ramp; this one is the practitioner’s depth, especially on service accounts and keyless authentication. When access doesn’t work, the companion GCP ‘Permission Denied’? The IAM Troubleshooting Decision Tree That Always Finds It is the debugging playbook. If you are building a multi-team landing zone, GCP Landing Zone: The Foundation Blueprint with Shared VPC and Org Policies shows where IAM design lives at scale.

Here is the one-screen map of where each IAM concept lives, so the rest of the article has a frame:

Layer What lives here You control it with Example
Identity Who is making the call Cloud Identity / Workspace; service accounts alice@kloudvin.com, etl-runner@proj.iam...
Grant What they may do A role bound to the principal roles/storage.objectViewer
Scope Where it applies The resource the binding sits on A bucket, a project, a folder
Policy The full set of grants The allow policy on a resource The list of all bindings
Boundary What is forbidden regardless A deny policy “Nobody may delete buckets”
Condition When/under what context An IAM Condition on a binding “Only until 2026-12-31”

Core concepts

Five ideas make every later section obvious. Read these once; the rest of the article is detail hanging off them.

IAM is a question, asked on every call: “Is this principal allowed to perform this action on this resource?” A principal (the identity — a user, a service account, a group) wants to perform an action (an IAM permission like storage.objects.get) on a resource (a bucket, a VM, a dataset). IAM looks at the policies attached to that resource and its ancestors and answers allow or deny. That’s it. Everything else is how you shape that answer.

You don’t grant permissions directly — you grant roles, and roles are bundles of permissions. Nobody writes “give Alice storage.objects.get and storage.objects.list.” You give Alice a roleroles/storage.objectViewer — which contains those permissions. A role is just a named collection of permissions. There are three kinds (basic, predefined, custom), and choosing the right kind is half of doing IAM well.

A grant is a binding, and the set of bindings on a resource is its allow policy. A binding glues one role to one or more principals: {members: [Alice, the data-readers group], role: roles/bigquery.dataViewer}. The allow policy (also called the IAM policy) of a resource is simply the list of all its bindings. When you “set IAM,” you are editing this policy document. It is additive — IAM grants are union’d together, there is no “deny” in the allow policy itself (denial is a separate mechanism we cover later).

Policies flow downhill through the resource hierarchy. GCP resources form a tree: organization → folders → projects → individual resources (buckets, VMs, datasets). A policy set on a node applies to that node and everything beneath it. Grant someone roles/viewer at the organization level and they can view every project in the company. Grant roles/storage.objectAdmin at the project level and they can administer objects in every bucket in that project. Effective access at any resource is the union of its own policy plus every ancestor’s policy. This is the single biggest source of “why can they do that?” surprises — the binding isn’t on the resource, it’s three levels up.

Workloads are identities too, and they use service accounts. A human signs in with a password and MFA. A VM, a container, a CI pipeline cannot type a password. So GCP gives non-human workloads their own identity type: a service account. The crucial modern principle is that a well-run workload never holds a key file. Instead it gets a short-lived token automatically — from the metadata server if it runs on GCP, or via Workload Identity Federation if it runs outside GCP. Keys are the legacy fallback you avoid.

The vocabulary in one table

Pin down every term before the deep sections. The glossary at the end repeats these for lookup; this is the mental model side by side:

Term One-line definition Example
Principal (member) An identity that can be granted access user:alice@kloudvin.com, serviceAccount:etl@proj.iam...
Permission The right to call one API method storage.objects.get
Role A named bundle of permissions roles/storage.objectViewer
Binding One role attached to one or more principals {role: …, members: […]}
Allow policy All the bindings on a resource The resource’s full IAM document
Resource The thing being accessed A bucket, VM, dataset, project
Resource hierarchy Org → folders → projects → resources The tree policies inherit down
Service account An identity for a workload, not a person run-svc@proj.iam.gserviceaccount.com
Attached service account The SA a VM/Cloud Run runs as The VM’s identity for outbound calls
Metadata server The on-host endpoint that hands out tokens 169.254.169.254
Workload Identity Federation External identities → GCP, no key GitHub Actions assuming a GCP SA
IAM Condition A rule limiting when/where a binding applies “only on buckets named logs-*
Deny policy An explicit “forbidden regardless of allows” “no one deletes datasets”
IAM Recommender The advisor that flags over-broad grants “remove unused roles/owner

Principals: who can be granted access

A principal (the API and docs also say member) is any identity IAM can put in a binding. Knowing the types matters because the prefix you write in a gcloud command — user:, group:, serviceAccount: — declares which type you mean, and choosing the wrong type is a common beginner mistake.

Principal type Identifier format Who/what it represents Typical use
User user:alice@kloudvin.com One human in Cloud Identity / Workspace A specific person’s access (prefer via a group)
Group group:data-team@kloudvin.com A Google Group of users/SAs The right way to grant humans — manage membership, not bindings
Service account serviceAccount:etl@proj.iam.gserviceaccount.com A workload identity VMs, Cloud Run, pipelines, automation
Domain domain:kloudvin.com Everyone in a Workspace domain Broad internal sharing (use sparingly)
allAuthenticatedUsers (literal) Any Google account, anywhere Almost never — public-ish, still authenticated
allUsers (literal) Anyone on the internet, no auth Only truly public assets (a public website bucket)
Workload identity pool principal principal://… / principalSet://… A federated external identity (GitHub, AWS) Keyless access from outside GCP

Two rules a beginner should internalise immediately. Bind roles to groups, not to individual users. When Alice joins the data team you add her to data-team@kloudvin.com and she inherits every grant the group has; when she leaves you remove her from the group and all access evaporates — no hunting through dozens of resources. Binding directly to user:alice@ means her access is scattered across the project and outlives her. Treat allUsers as radioactive. It means the public internet, unauthenticated. There are legitimate uses (a bucket serving a public static website) but a allUsers binding on anything containing data is how buckets end up in breach headlines.

A note on identifiers you’ll see in policies: every Google account is also internally a numeric ID, and when a user or service account is deleted, IAM may rewrite the binding to deleted:user:…?uid=…. Those stale entries are harmless but worth cleaning up — they’re a sign a principal was removed without its bindings being tidied.

Roles: basic, predefined, and custom

A role is a bundle of permissions. There are exactly three kinds, and the entire art of “right-sized access” is choosing among them.

Role type What it is How many permissions When to use The danger
Basic (primitive) The legacy roles/owner, roles/editor, roles/viewer Thousands, project-wide Almost never in production; quick personal sandbox only Wildly over-broad; Editor can change/destroy nearly everything
Predefined Google-curated roles per service Tens, scoped to a service Your default for 95% of grants Occasionally broader than you’d like — read what it contains
Custom A role you define with a hand-picked permission list Exactly what you choose When no predefined role fits and you need tighter scope You maintain it; it can drift; new API perms aren’t auto-added

Basic roles — what they really mean (and why to avoid them)

The three basic roles predate fine-grained IAM and are deliberately coarse. They are tempting because they’re easy, and they are the single most common cause of over-privilege.

Basic role Roughly what it grants Why it’s dangerous
roles/viewer Read-only across (almost) all services in the scope Can read every secret-adjacent config, every dataset’s schema, every bucket listing — broad data exposure
roles/editor Viewer + create/modify/delete most resources Can delete datasets, buckets, VMs, change configs — a script bug is catastrophic
roles/owner Editor + manage IAM (grant roles, including to themselves) Full control including handing out access — effectively admin of the scope

The trap with roles/editor: it sounds like “can edit things,” but it can also delete most things and create new resources (which cost money). The trap with roles/owner: it can modify IAM, which means an owner can grant themselves or anyone else any further access — there is no higher privilege to contain them within that scope. Use these only in a throwaway personal project. In any shared or production project, reach for predefined roles.

Predefined roles — your default

Google publishes hundreds of predefined roles, named roles/<service>.<capability>. They follow a readable pattern: viewer (read), dataViewer/objectViewer (read data specifically), admin (full control of that service), and granular ones in between. A few you’ll meet constantly:

Predefined role Service What it lets you do What it deliberately does NOT do
roles/storage.objectViewer Cloud Storage Read objects in a bucket List buckets, write, delete, change bucket IAM
roles/storage.objectAdmin Cloud Storage Read/write/delete objects Delete the bucket itself, change bucket-level IAM
roles/bigquery.dataViewer BigQuery Read table data and metadata Run queries that cost money, edit/delete tables
roles/bigquery.dataEditor BigQuery Read/write table data, create tables Delete the dataset, manage dataset IAM
roles/bigquery.jobUser BigQuery Run query/load/export jobs (incurs cost) See data unless paired with a data role
roles/run.invoker Cloud Run Call (invoke) a Cloud Run service Deploy, modify, or read the service config
roles/cloudsql.client Cloud SQL Connect to an instance Create/delete instances, manage backups
roles/logging.viewer Cloud Logging Read logs Write log-based metrics, manage sinks
roles/iam.serviceAccountUser IAM “Run as” / attach a service account Create or delete service accounts
roles/iam.workloadIdentityUser IAM Let an external identity impersonate an SA Anything else

Notice the deliberate gaps in the right-hand column — that’s the whole point. roles/bigquery.dataEditor can write tables but cannot delete the dataset; that single distinction is what would have prevented the incident in the opening story. Always read what a predefined role contains before granting it:

# What permissions does this predefined role actually include?
gcloud iam roles describe roles/bigquery.dataEditor

# Search for roles that contain a specific permission you need
gcloud iam roles list --filter="includedPermissions:bigquery.tables.create" \
  --format="table(name)"

Custom roles — only when nothing fits

A custom role is a permission list you author yourself, living either at the project or organization level. Reach for it only when predefined roles are all too broad and you have a specific, lasting need — not for a one-off.

# Create a custom role: can read AND write objects but explicitly cannot delete them
gcloud iam roles create objectReadWriteNoDelete \
  --project=kloudvin-prod \
  --title="Object Read/Write (no delete)" \
  --description="Read and write GCS objects; cannot delete" \
  --permissions=storage.objects.get,storage.objects.list,storage.objects.create \
  --stage=GA

Custom roles carry real maintenance cost, and a beginner should know the trade-offs before committing:

Consideration Predefined role Custom role
Who maintains it Google (new permissions added as services evolve) You — new API permissions are not auto-added
Drift risk Low High — easy to leave over-broad or stale
Where it can live Everywhere Project or organization level only
Best for 95% of grants A genuinely unique, lasting permission set
Audit story Familiar names reviewers recognise Reviewers must read your permission list

Audit custom roles on a schedule — quarterly is a sane default. The most common failure is a custom role that was tight when created, then had permissions piled on over two years until it’s broader than the predefined role you were avoiding.

Bindings and the allow policy

When you “grant access,” you are adding a binding to a resource’s allow policy. A binding is {role, members[], condition?}. The policy is the list of bindings plus an etag (a version stamp that prevents two people clobbering each other’s edits).

The everyday way to add a single binding:

# Grant the data-readers group read access to one dataset's data, at the project level
gcloud projects add-iam-policy-binding kloudvin-prod \
  --member="group:data-readers@kloudvin.com" \
  --role="roles/bigquery.dataViewer"

There is a parallel add-iam-policy-binding / remove-iam-policy-binding for almost every resource type — gcloud storage buckets add-iam-policy-binding, gcloud run services add-iam-policy-binding, and so on — and that scope (project vs bucket vs service) is exactly where the binding lands in the hierarchy. To see the full policy on a resource:

# Read the entire allow policy of a project (every binding)
gcloud projects get-iam-policy kloudvin-prod --format=json

A small real policy reads like this — and once you can read this by eye, IAM stops being mysterious:

{
  "bindings": [
    {
      "role": "roles/bigquery.dataViewer",
      "members": ["group:data-readers@kloudvin.com"]
    },
    {
      "role": "roles/bigquery.dataEditor",
      "members": ["serviceAccount:etl-runner@kloudvin-prod.iam.gserviceaccount.com"]
    },
    {
      "role": "roles/owner",
      "members": ["user:founder@kloudvin.com"]
    }
  ],
  "etag": "BwYF...",
  "version": 1
}

Two operational cautions that save real pain:

Command What it does The gotcha
add-iam-policy-binding Adds one binding, leaves the rest alone Safe for everyday grants — prefer this
remove-iam-policy-binding Removes one binding Safe; targeted
set-iam-policy Replaces the entire policy with a file you supply Dangerous — easy to wipe other bindings; needs the current etag to avoid races

set-iam-policy is for scripted, reviewed, full-policy management (often via Terraform). For learning and day-to-day work, stick to the additive add/remove commands — you cannot accidentally delete someone else’s access with them.

Service accounts: identities for workloads

A service account (SA) is a special kind of principal that represents a workload — an application, a VM, a pipeline — rather than a person. It has an email-shaped identifier ending in .iam.gserviceaccount.com, it can be granted roles like any principal, and other principals can be granted roles on it (because the SA is itself a resource you might want to control who can use). That dual nature — an SA is both an identity you grant to and a resource you grant access over — is the one thing beginners find confusing, so hold onto it.

There are a few flavours of service account, and they are not interchangeable:

Service account type Created by Identifier example You manage it? Use it?
User-managed SA You etl-runner@kloudvin-prod.iam.gserviceaccount.com Yes — create, name, grant, delete Yes — one per workload, scoped tightly
Default compute SA Google (auto, per project) <num>-compute@developer.gserviceaccount.com Mostly hands-off Avoid for prod — historically very broad (Editor)
Default App Engine SA Google (auto) <project>@appspot.gserviceaccount.com Mostly hands-off Avoid relying on its defaults
Google-managed service agent Google service-<num>@gcp-sa-<service>.iam... No — Google manages it Don’t touch — it lets a Google service act for you

Two of those deserve a warning. The default compute service account is created automatically and, on older projects, was granted the broad roles/editor — meaning every VM created without specifying an SA could, by default, edit the whole project. (Google has tightened newer-project defaults, but you should never rely on the default SA for anything real.) The Google-managed service agents (gcp-sa-*) are how Google’s own services act on your behalf (e.g. so Pub/Sub can push to a subscription); you neither create nor manage these — leave them alone.

Create and configure a user-managed SA like this:

# 1) Create a dedicated SA for one workload
gcloud iam service-accounts create etl-runner \
  --project=kloudvin-prod \
  --display-name="ETL batch runner"

# 2) Grant it ONLY the roles that one workload needs — at the smallest scope
gcloud projects add-iam-policy-binding kloudvin-prod \
  --member="serviceAccount:etl-runner@kloudvin-prod.iam.gserviceaccount.com" \
  --role="roles/bigquery.jobUser"

gcloud storage buckets add-iam-policy-binding gs://kloudvin-etl-staging \
  --member="serviceAccount:etl-runner@kloudvin-prod.iam.gserviceaccount.com" \
  --role="roles/storage.objectAdmin"

Service-account keys — the thing to avoid

A service-account key is a downloaded JSON file containing a private key that lets anything holding the file authenticate as that SA. It works anywhere, never expires by default, and is therefore exactly the kind of credential that leaks into Git, CI logs and laptops. Treat key creation as a last resort:

Authentication method Key file? Expires? Where it works Recommendation
Attached SA + metadata token No Yes (auto-rotated, ~1 h) Workloads on GCP (CE, GKE, Cloud Run, Functions) Default for on-GCP workloads
Workload Identity Federation No Yes (short-lived) Workloads outside GCP (GitHub, AWS, on-prem) Default for off-GCP workloads
SA impersonation No Yes (short-lived) A human/SA temporarily acting as another SA Good for admin/break-glass
Downloaded SA key (JSON) Yes No (unless you rotate) Anywhere Last resort — only if nothing above works

If you have leaked or suspect a leaked key, the response is immediate: disable then delete the key, and rotate anything it could reach. Never re-commit a key to source control. (For the same reason, scan repos and CI for .json credentials before they’re pushed.) The whole point of the next two sections is that you almost never need a key in the first place.

# List keys on an SA (look for old, forgotten, user-managed keys)
gcloud iam service-accounts keys list \
  --iam-account=etl-runner@kloudvin-prod.iam.gserviceaccount.com

# Disable a suspicious key immediately, then delete it
gcloud iam service-accounts keys disable KEY_ID \
  --iam-account=etl-runner@kloudvin-prod.iam.gserviceaccount.com

Keyless on GCP: attached service accounts and the metadata token

Here is the mechanism that makes “no key file” possible for anything running inside Google Cloud, and it’s worth understanding because it feels like magic until you see it.

Every Compute Engine VM, GKE node/pod, Cloud Run service and Cloud Function runs as a service account — its attached service account. The platform exposes a special, link-local endpoint called the metadata server at 169.254.169.254 (also reachable as metadata.google.internal). Code running on the workload asks the metadata server for an access token; the metadata server returns a short-lived OAuth token (valid roughly an hour) for the attached SA. The Google client libraries do this automatically via Application Default Credentials (ADC) — you write storage.Client() with no credentials and it Just Works, because under the hood it fetched a token from the metadata server. No file, no secret, and the token rotates itself.

The flow, hop by hop:

Step What happens Who does it
1 A VM is created with an attached SA (--service-account=…) You, at deploy time
2 Your app calls a Google API (e.g. read a GCS object) via a client library Your code
3 The library finds no static credentials → asks the metadata server Google client library (ADC)
4 The metadata server returns a short-lived token for the attached SA The platform
5 The library calls the API with that token; IAM checks the SA’s roles Library + IAM
6 ~1 hour later the token expires; the library fetches a fresh one Automatic

Attach an SA at create time and grant it least privilege:

# Create a VM that runs AS the etl-runner SA — no key file anywhere
gcloud compute instances create etl-vm \
  --project=kloudvin-prod --zone=asia-south1-a \
  --service-account="etl-runner@kloudvin-prod.iam.gserviceaccount.com" \
  --scopes="https://www.googleapis.com/auth/cloud-platform"

For Cloud Run it’s the same idea — --service-account sets the identity the service runs as:

gcloud run deploy orders-api \
  --image=asia-south1-docker.pkg.dev/kloudvin-prod/apps/orders:1.4.2 \
  --service-account="orders-api-sa@kloudvin-prod.iam.gserviceaccount.com" \
  --region=asia-south1 --no-allow-unauthenticated

Two beginner gotchas live here. Access scopes vs IAM roles (legacy): on Compute Engine there’s an older concept called access scopes that can cap what APIs a VM may call on top of IAM. The modern guidance is to set the broad cloud-platform scope and control access purely with IAM roles on the SA — but if a VM gets PERMISSION_DENIED despite the SA having the role, a too-narrow legacy scope is a classic cause. The actAs permission: to create a VM or deploy a service that runs as an SA, your own identity needs roles/iam.serviceAccountUser (the iam.serviceAccounts.actAs permission) on that SA — otherwise you can’t attach it. That check exists so a low-privilege user can’t “borrow” a high-privilege SA by attaching it to a VM they control.

For GKE specifically, the recommended pattern is GKE Workload Identity, which maps a Kubernetes service account to a Google service account so pods get tokens the same keyless way (covered more in GKE-focused material; the principle is identical — pods get short-lived tokens, never a mounted key).

Keyless from outside GCP: Workload Identity Federation

The metadata-server trick only works inside GCP. So how does a GitHub Actions pipeline, an AWS Lambda, or an on-prem job authenticate to Google Cloud without a downloaded key? The answer — and the single most important “modern security” idea in this article — is Workload Identity Federation (WIF).

WIF lets you tell GCP: “Trust tokens issued by this external identity provider (GitHub’s OIDC, AWS STS, Azure AD, any OIDC/SAML IdP). When an external workload presents a valid token from that provider matching these attributes, let it impersonate this service account and get a short-lived GCP token.” No GCP key is ever created. The external system already has its own identity (GitHub gives each workflow run an OIDC token); WIF exchanges that for temporary GCP credentials.

The pieces:

WIF component What it is Example
Workload identity pool A container for external identities github-pool
Pool provider The trusted IdP + attribute mapping/condition GitHub OIDC, restricted to repo:kloudvin/app
Attribute mapping Maps IdP token claims → GCP attributes google.subject = assertion.sub
Attribute condition A filter on which external tokens are accepted assertion.repository == 'kloudvin/app'
Target service account The GCP SA the external identity may impersonate deployer@kloudvin-prod.iam...
workloadIdentityUser binding Lets the pool principal impersonate that SA principalSet://… → SA

Why this matters versus a key, in plain terms:

Property Downloaded SA key Workload Identity Federation
A secret file exists Yes (and can leak) No file at all
Lifetime Forever (until rotated) Minutes (short-lived token)
Scope of trust Anyone with the file Only tokens matching your attribute condition
Revocation Delete the key (if you find it) Remove the binding / tighten the condition
Audit “the key was used” “this exact GitHub repo+branch was used”

Setting it up for GitHub Actions, end to end:

# 1) Create a workload identity pool
gcloud iam workload-identity-pools create github-pool \
  --project=kloudvin-prod --location=global \
  --display-name="GitHub Actions pool"

# 2) Add GitHub's OIDC as a provider, mapping claims and RESTRICTING to one repo
gcloud iam workload-identity-pools providers create-oidc github-provider \
  --project=kloudvin-prod --location=global \
  --workload-identity-pool=github-pool \
  --issuer-uri="https://token.actions.githubusercontent.com" \
  --attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository" \
  --attribute-condition="assertion.repository=='kloudvin/app'"

# 3) Let identities from that repo impersonate the deployer SA (NO key created)
gcloud iam service-accounts add-iam-policy-binding \
  deployer@kloudvin-prod.iam.gserviceaccount.com \
  --project=kloudvin-prod \
  --role="roles/iam.workloadIdentityUser" \
  --member="principalSet://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/github-pool/attribute.repository/kloudvin/app"

The attribute condition is the security boundary — assertion.repository=='kloudvin/app' means only workflows from that specific repository are trusted, not “any GitHub repo on the internet.” Forgetting that condition is the dangerous mistake: an unrestricted provider would let any GitHub repository assume your SA. Always scope WIF providers to the exact external identity you intend to trust.

Least privilege: the principle that ties it together

Least privilege means each principal gets exactly the permissions its job requires — no broader role, no broader scope — and nothing more. It’s not a feature you enable; it’s a discipline expressed through every choice above. Translated into concrete habits:

Least-privilege habit Over-privileged anti-pattern What you do instead
Right role roles/editor to “make it work” The narrowest predefined role that covers the task
Right scope Bind at project (or org) level Bind on the single bucket/dataset/service
Right identity One shared SA for many jobs One SA per workload, named for its job
Right credential Downloaded key file Attached SA + metadata token, or WIF
Right lifetime Permanent grant Time-bound via an IAM Condition; remove when done
Right humans Direct user: bindings Bind to groups; manage membership

Two practical dimensions of “narrow”: role narrowness (give roles/storage.objectViewer, not roles/storage.admin, when read is all that’s needed) and scope narrowness (grant on gs://one-bucket, not on the whole project). Scope narrowness is the one beginners forget — a perfectly chosen role bound at the project level still grants that capability across every matching resource in the project. The rule of thumb: bind at the lowest node in the hierarchy that still satisfies the requirement. If only one bucket is involved, the binding belongs on that bucket.

IAM Conditions: access only when it makes sense

An IAM Condition is an extra rule attached to a binding that must be true for the grant to apply. It uses CEL (Common Expression Language) and can gate access by resource name/type, by time, by request context, and more. It lets you say “this role, but only on resources named logs-*” or “only until the end of the quarter.”

Common condition shapes a beginner will actually use:

You want CEL expression (the --condition expression) Use case
Only certain resources by name resource.name.startsWith("projects/_/buckets/logs-") Scope a broad role to one naming prefix
Only a certain resource type resource.type == "storage.googleapis.com/Bucket" Limit to buckets, not other resources
Only until a date (temporary) request.time < timestamp("2026-12-31T00:00:00Z") Time-bound / break-glass access
Only during business hours request.time.getHours("Asia/Kolkata") >= 9 && request.time.getHours("Asia/Kolkata") < 18 Restrict admin actions to working hours

A real time-bound grant — exactly how you’d give a contractor read access that expires automatically:

gcloud projects add-iam-policy-binding kloudvin-prod \
  --member="user:contractor@example.com" \
  --role="roles/storage.objectViewer" \
  --condition='expression=request.time < timestamp("2026-12-31T00:00:00Z"),title=temp-until-year-end,description=Contractor read access expiring 2026-12-31'

Things to know before relying on conditions:

Aspect Detail
Language CEL — small, safe expression language (string, time, comparison operators)
Where it sits On the binding, not the whole policy — each binding can have its own condition
What it can read resource.* (name, type, service) and request.* (time, and limited context)
What it cannot do It can’t reference arbitrary attributes — only the supported resource/request fields
Policy version Conditional bindings require IAM policy version 3 (the tooling sets this for you)
Gotcha A typo’d condition that’s always false silently denies — test the access after adding

Conditions are how you make least privilege temporal and contextual, not just “which role.” A time-bound condition on break-glass access is far safer than a permanent owner grant you’ll forget to remove.

Deny policies: an explicit “no” that wins

The allow policy can only grant. To express “no one may do X, regardless of any role they hold,” you use a separate mechanism: a deny policy. Deny rules are evaluated before allows and a matching deny wins — so a deny policy is a hard guardrail that even an Owner cannot bypass (unless explicitly excepted).

Aspect Allow policy Deny policy
Effect Grants permissions Forbids permissions
Evaluation order After deny First — a matching deny short-circuits the allow
Attaches to Resources (org/folder/project/resource) Org, folder, or project (set via the Policies API)
Typical use Day-to-day access Org-wide guardrails (“never delete logs buckets”, “no SA key creation”)
Beginner advice Use constantly Use sparingly, for true must-never rules

A deny policy is defined as a JSON document listing denied permissions and the principals it applies to (with optional exceptions and a CEL condition), then attached with gcloud iam policies create. A classic guardrail — nobody may create service-account keys org-wide (because keys are the leak vector) — looks conceptually like:

{
  "displayName": "Block SA key creation",
  "rules": [
    {
      "denyRule": {
        "deniedPrincipals": ["principalSet://goog/public:all"],
        "deniedPermissions": ["iam.googleapis.com/serviceAccountKeys.create"],
        "exceptionPrincipals": ["principalSet://goog/group/break-glass@kloudvin.com"]
      }
    }
  ]
}

The mental model: allow policies say what’s possible; deny policies say what’s forbidden no matter what. Use deny for the handful of rules that must hold even if someone is accidentally over-granted — they’re your seatbelt for IAM mistakes.

IAM Recommender: shrinking over-grants automatically

Over time, grants accumulate and most go partly unused. The IAM Recommender (part of the Policy Intelligence suite) watches actual permission usage over ~90 days and recommends removing or replacing roles that are broader than what a principal actually uses — e.g. “this SA has roles/editor but only ever used storage.objects.*; replace it with roles/storage.objectAdmin.”

Recommender capability What it surfaces
Role recommendations “Replace this over-broad role with a narrower one” / “remove this unused role”
Lateral movement insights An SA in project A that could impersonate into project B (risky reach)
Policy/permission insights Unused permissions, large gaps between granted and used
Service account insights Unused service accounts and unused keys you should remove

Pull recommendations from the CLI and act on the safe ones:

# See IAM role recommendations for a project (what to shrink/remove)
gcloud recommender recommendations list \
  --project=kloudvin-prod \
  --location=global \
  --recommender=google.iam.policy.Recommender \
  --format="table(name, primaryImpact.category, content.overview)"

Use the Recommender as a monthly hygiene pass: review its suggestions, apply the obviously-correct ones (removing genuinely unused roles/owner from a service account is almost always right), and investigate the surprising ones. It is the closest thing to “least privilege on autopilot,” but it’s an advisor — a human still decides, because a permission used once a year would otherwise look removable.

Architecture at a glance

The two diagrams below show the two halves of everything above. The first is the decision flow every request travels: a principal (a user, a group member, or a service account) attempts an action on a resource; IAM gathers the allow policy on that resource plus every ancestor’s policy up the hierarchy (and any deny policy and condition), evaluates whether a binding grants the needed permission, and returns allow or deny. Read it left to right: identity on the left, the policy evaluation in the middle, the resource and the allow/deny verdict on the right. This is the picture to hold in your head whenever you debug “why can(’t) they do that” — the answer is always “what bindings, at what scope, with what conditions, apply to this principal for this resource.”

GCP IAM decision flow: a user or service-account principal requests an action on a resource; IAM evaluates the allow policy and inherited policies plus any deny policy and condition to return allow or deny

The second diagram is the service-account-in-action picture — the keyless mechanism from the “attached SA and metadata token” section made concrete. A Compute Engine VM (or Cloud Run service) runs as an attached service account; when its code calls Cloud Storage or Cloud SQL, the Google client library transparently fetches a short-lived token for that SA from the metadata server, and IAM grants or denies each call based on the roles bound to the SA — read access to one bucket, client access to one Cloud SQL instance — with no key file anywhere on the machine. Follow the arrows from the workload, through the attached identity, to each downstream API gated by that identity’s roles.

A Compute Engine VM running as an attached service account uses short-lived tokens to access a Cloud Storage bucket and a Cloud SQL instance, each call gated by the service account's IAM roles, with no key file present

Real-world scenario

Kloudvin Retail runs a nightly analytics pipeline on GCP. The original setup, built in a hurry, looked like this: a single shared service account, automation@kloudvin-prod.iam.gserviceaccount.com, with roles/editor at the project level; a downloaded automation-key.json checked into the pipeline’s Git repo “temporarily”; and every one of the eight data engineers holding roles/editor on the project so they could “debug.” It worked — until a refactor of the nightly job had a typo in a cleanup step that issued bq rm -r -f against the wrong dataset. Because the job ran as an Editor service account, the delete succeeded. Eleven months of curated tables were gone, and the key.json in Git meant the blast radius was anything Editor could touch, from anywhere.

The remediation was a textbook least-privilege rebuild, and the numbers tell the story. Identity: they created three purpose-built service accounts — etl-loader (loads raw files into BigQuery), etl-transform (runs transformation queries), and report-reader (read-only for the BI tool) — replacing the one shared Editor SA. Roles: etl-loader got roles/bigquery.dataEditor on only the raw dataset plus roles/storage.objectViewer on the ingest bucket; etl-transform got roles/bigquery.jobUser (to run queries) plus roles/bigquery.dataEditor on the analytics dataset only; report-reader got roles/bigquery.dataViewer on analytics and nothing else. Crucially, none of them got dataset-delete permission — bigquery.datasets.delete lives in admin roles they deliberately didn’t grant.

Credentials: the key.json was deleted from Git (and the key disabled then deleted in IAM), history was scrubbed, and the pipeline — which runs on Compute Engine — was switched to attached service accounts so it gets metadata-server tokens with no key file at all. The GitHub Actions deploy job moved to Workload Identity Federation scoped to assertion.repository=='kloudvin/data-pipeline'. Humans: the eight engineers lost their direct roles/editor bindings; instead a data-engineers@kloudvin.com group got roles/bigquery.dataViewer plus roles/bigquery.jobUser for day-to-day work, with a time-bound break-glass condition granting elevated access only when explicitly enabled. A deny policy at the project level now forbids bigquery.datasets.delete for everyone except a tiny break-glass group.

The result, six weeks later: the IAM Recommender showed zero roles/editor or roles/owner on any automation principal, the same typo’d cleanup step (re-run in a test) now fails with PERMISSION_DENIED instead of deleting anything, and a quarterly audit takes an afternoon instead of being impossible. The blast radius of a bug went from “the entire project” to “the specific tables this specific job is allowed to write.” No new product was bought; the fix was discipline.

Advantages and disadvantages

Advantages of doing IAM well Disadvantages / costs to be aware of
Fine-grained control at org/folder/project/resource level More bindings to design and reason about than “Editor for all”
Service accounts + keyless auth remove long-lived secrets Initial setup (SAs, WIF, conditions) takes more thought up front
Policy inheritance scales governance org-wide Inheritance can grant unintended access from a high binding
Predefined roles are curated and maintained by Google Some predefined roles are broader than you’d ideally want
Conditions and deny policies add temporal/hard guardrails Custom roles drift and need ongoing maintenance
Recommender turns “least privilege” into a measurable practice Recommender is advisory — still needs human judgement

Where each matters: inheritance is a double-edged sword — it’s what lets you grant “auditors can view everything” once at the org, and it’s also why a stray roles/editor at the folder level quietly grants Editor across a dozen projects. The skill is to keep broad grants high and rare, and specific grants low and many. Keyless auth is almost pure upside; the only “cost” is learning the metadata-server/WIF model once. Custom roles are where the maintenance burden concentrates — every custom role is a small ongoing liability, which is why “predefined first” is the rule.

Hands-on lab

A from-scratch, free-tier-friendly walkthrough: create a service account, grant it least privilege on one bucket, prove it can read but not delete, add a time-bound human grant, and clean up. Use Cloud Shell (already authenticated) or a local gcloud. Costs are effectively zero (a tiny bucket and a few API calls).

Step 1 — Set your project variable.

export PROJECT=$(gcloud config get-value project)
echo "Using project: $PROJECT"

Step 2 — Create a bucket and a test object.

gcloud storage buckets create gs://${PROJECT}-iam-lab --location=asia-south1
echo "hello least privilege" | gcloud storage cp - gs://${PROJECT}-iam-lab/test.txt

Expected: the bucket is created and test.txt uploads. List it to confirm:

gcloud storage ls gs://${PROJECT}-iam-lab/
# gs://<project>-iam-lab/test.txt

Step 3 — Create a least-privilege service account.

gcloud iam service-accounts create lab-reader \
  --display-name="Lab read-only SA"

Step 4 — Grant it read-only on ONLY this bucket (not the project).

gcloud storage buckets add-iam-policy-binding gs://${PROJECT}-iam-lab \
  --member="serviceAccount:lab-reader@${PROJECT}.iam.gserviceaccount.com" \
  --role="roles/storage.objectViewer"

This is the least-privilege move: objectViewer (read, not write/delete) on one bucket (not the project). Verify the binding landed:

gcloud storage buckets get-iam-policy gs://${PROJECT}-iam-lab \
  --format="table(bindings.role, bindings.members)"
# Expect roles/storage.objectViewer with the lab-reader SA

Step 5 — Prove the boundary by impersonating the SA. Your user can impersonate the SA if you have roles/iam.serviceAccountTokenCreator on it (grant it to yourself for the lab):

ME=$(gcloud config get-value account)
gcloud iam service-accounts add-iam-policy-binding \
  lab-reader@${PROJECT}.iam.gserviceaccount.com \
  --member="user:${ME}" --role="roles/iam.serviceAccountTokenCreator"

# Read AS the SA — should SUCCEED (objectViewer allows read)
gcloud storage cat gs://${PROJECT}-iam-lab/test.txt \
  --impersonate-service-account=lab-reader@${PROJECT}.iam.gserviceaccount.com
# Expected output: hello least privilege

# Delete AS the SA — should FAIL (objectViewer does NOT allow delete)
gcloud storage rm gs://${PROJECT}-iam-lab/test.txt \
  --impersonate-service-account=lab-reader@${PROJECT}.iam.gserviceaccount.com
# Expected: ERROR ... 403 ... does not have storage.objects.delete access

That contrast — read succeeds, delete is denied with a 403 storage.objects.delete error — is least privilege working. The SA can do exactly its job and nothing more.

Step 6 — Add a time-bound human grant (a condition).

gcloud storage buckets add-iam-policy-binding gs://${PROJECT}-iam-lab \
  --member="user:${ME}" \
  --role="roles/storage.objectViewer" \
  --condition='expression=request.time < timestamp("2026-12-31T00:00:00Z"),title=lab-temp,description=Temporary lab read access'

Validation checklist. You created a bucket, made a dedicated read-only SA, bound it on only that bucket, proved read works and delete is blocked, and added a self-expiring human grant. You never created or downloaded a key.

Teardown (so nothing lingers).

gcloud storage rm --recursive gs://${PROJECT}-iam-lab
gcloud iam service-accounts delete \
  lab-reader@${PROJECT}.iam.gserviceaccount.com --quiet

Cost note. A near-empty bucket and a handful of operations cost effectively nothing; deleting the bucket and SA removes everything. There is no per-hour charge for IAM itself.

Common mistakes & troubleshooting

The failures a beginner actually hits, with how to confirm and fix each.

# Symptom Root cause Confirm (command / where) Fix
1 PERMISSION_DENIED despite “having access” The role is bound to a group you’re not in, or at a scope below the resource gcloud projects get-iam-policy $P + check group membership Add yourself to the group, or bind the role at the right scope
2 A VM gets 403 calling an API even though its SA has the role Legacy access scope on the VM too narrow gcloud compute instances describe VM --format="value(serviceAccounts.scopes)" Recreate/set scope to cloud-platform; control via IAM roles
3 “You do not have permission to act as service account” when creating a VM/Cloud Run Your identity lacks iam.serviceAccounts.actAs on the SA The error names the SA Grant yourself roles/iam.serviceAccountUser on that SA
4 Access works for you but not for a teammate You bound user:you instead of a group Read the policy; you’ll see your user, not a group Rebind to a group; add the teammate to it
5 Granted a role but still denied Binding has a condition that’s false (typo’d or expired) get-iam-policy --format=json and read the condition Fix/remove the condition; remember version-3 policy
6 Removed a role but access persists The principal still has it from an ancestor (folder/org) binding Check policies at project and folder and org Remove the binding at the inheriting level
7 Everyone suddenly can’t do something an Owner should A deny policy forbids it gcloud iam policies list --attachment-point … Add an exception principal, or scope/remove the deny rule
8 App works locally but fails on GCP (or vice versa) Different credentials: local key vs metadata-server ADC Check GOOGLE_APPLICATION_CREDENTIALS env var presence Use ADC/attached SA on GCP; impersonation locally — drop the key
9 GitHub Actions can’t auth via WIF Attribute condition doesn’t match the repo, or binding missing Compare provider attribute-condition to the actual repo claim Fix the condition; add the workloadIdentityUser binding for the right principalSet
10 A binding references deleted:user:…?uid=… The principal was deleted but its binding remained get-iam-policy shows the deleted: prefix Remove the stale binding
11 New SA can’t do anything You created the SA but never granted it any role get-iam-policy shows no binding for it Add the least-privilege role it needs
12 Token/credential errors after key rotation Code still loads an old key file Look for a key.json path in config/env Move to keyless (metadata/WIF); delete the key

The two most common by far are #1/#6 (inheritance confusion) and #3 (actAs). For inheritance: when access surprises you, always check the whole chain — resource, then project, then folder, then org — because the binding is often not where you’re looking. For actAs: it’s not enough for the SA to have permissions; you need permission to attach it. The companion GCP ‘Permission Denied’? The IAM Troubleshooting Decision Tree That Always Finds It walks the full decision tree for these.

A quick “which scope holds the binding?” check that resolves most mysteries:

# Is the grant on the project, the folder, or the org? Check all three.
gcloud projects get-iam-policy kloudvin-prod --format=json | grep -A2 'role'
gcloud resource-manager folders get-iam-policy FOLDER_ID
gcloud organizations get-iam-policy ORG_ID

Best practices

Security notes

IAM is the security layer, so “security notes” here is mostly “do the above, deliberately.” A few points deserve emphasis:

For perimeter-level data-exfiltration protection that complements IAM (stopping data leaving even when IAM would allow it), see GCP VPC Service Controls: Build Data Exfiltration Perimeters.

Cost & sizing

IAM itself is free — there is no charge for principals, roles, bindings, conditions, deny policies, Workload Identity Federation, or using the IAM Recommender. The “cost” of IAM is indirect and entirely about discipline:

Cost dimension What it means How to keep it down
Operational cost Time designing/maintaining bindings, SAs, custom roles Predefined roles + groups; fewer custom roles; Recommender hygiene
Risk cost Blast radius of an over-grant (data loss, breach) Least privilege; keyless auth; deny guardrails
Audit cost Effort to prove who can do what Groups + standard roles make audits an afternoon, not a project
Indirect $$ An over-broad SA that can create resources can run up a bill Scope roles so workloads can’t spin up costly resources by accident

The free-tier reality: you can build a fully least-privilege IAM design — multiple service accounts, WIF for CI/CD, conditions, deny policies, Recommender reviews — at zero IAM cost, on any project including a free-tier one. The only things that cost money are the resources IAM protects (buckets, VMs, BigQuery jobs), not the access control. There is genuinely no financial reason to skip doing IAM properly; the only investment is thought.

Interview & exam questions

1. What are the four core components of GCP IAM? A principal (the identity), a role (a bundle of permissions), a binding (a role attached to one or more principals), and the allow policy (the full set of bindings on a resource). IAM answers “can this principal perform this action on this resource?” by evaluating that policy plus inherited ones.

2. Difference between basic, predefined, and custom roles — and which to prefer? Basic roles (owner/editor/viewer) are coarse and project-wide — avoid in production. Predefined roles are Google-curated, service-scoped, and the right default for ~95% of grants. Custom roles are hand-authored permission lists for when nothing predefined fits — powerful but you maintain them and they drift.

3. Why bind roles to groups instead of individual users? Access then follows group membership: add a person to the group and they inherit all its grants; remove them and access disappears everywhere at once. Direct user: bindings scatter access across resources and outlive the person, making offboarding and audits error-prone.

4. How does a Compute Engine VM (or Cloud Run service) authenticate to other Google APIs without a key file? It runs as an attached service account; the Google client libraries fetch a short-lived token for that SA from the metadata server (169.254.169.254) via Application Default Credentials. The token auto-rotates (~1 h). No key file exists on the machine.

5. What is Workload Identity Federation and why is it better than a downloaded key? WIF lets an external identity (GitHub OIDC, AWS, on-prem IdP) exchange its own token for short-lived GCP credentials by impersonating an SA — no GCP key is ever created. It’s better because there’s no long-lived secret to leak, trust is scoped by an attribute condition to a specific external identity, and credentials are short-lived.

6. Explain IAM policy inheritance. Resources form a tree (org → folders → projects → resources). A policy on a node applies to that node and everything beneath it; effective access at any resource is the union of its own policy and all ancestor policies. A broad binding high up grants everywhere below — the top cause of “why can they do that?”

7. What’s the difference between an allow policy and a deny policy? An allow policy grants permissions and is purely additive. A deny policy forbids permissions, is evaluated first, and a matching deny beats any allow (even Owner) unless explicitly excepted. Use deny for hard org-wide guardrails like “no one may delete logs buckets.”

8. When and how would you use an IAM Condition? To limit when/where a binding applies — e.g. only on resources named logs-*, only a certain resource type, or only until a date. You attach a CEL expression to the binding (e.g. request.time < timestamp("2026-12-31T00:00:00Z") for time-bound access). Conditional bindings use policy version 3.

9. What does the IAM Recommender do? It analyses ~90 days of actual permission usage and recommends shrinking over-broad grants — replacing a role with a narrower one, removing unused roles/SAs/keys, and flagging lateral-movement risk. It’s advisory; a human applies the safe recommendations.

10. A workload’s service account has the right role but still gets 403. Name two likely causes. (a) A legacy access scope on the VM is too narrow (set it to cloud-platform and control with IAM). (b) The binding is at the wrong scope or via a group the SA isn’t in, or a condition on the binding is false. Also possible: the role lacks the specific permission the call needs.

11. Why is roles/owner especially sensitive? Owner can modify IAM — grant roles, including to itself — so it’s effectively unbounded admin within its scope and can escalate further. Keep owners to a tiny audited set and prefer time-bound break-glass over standing owner grants.

12. What permission lets you attach/run-as a service account, and why does it exist? iam.serviceAccounts.actAs (in roles/iam.serviceAccountUser). It exists so a low-privilege user can’t grab a high-privilege SA by attaching it to a VM or Cloud Run service they control — you must be explicitly allowed to act as that identity.

These map to the Associate Cloud Engineer (managing IAM, service accounts, roles) and Professional Cloud Security Engineer (least privilege, Workload Identity Federation, conditions, deny policies, org guardrails) certifications.

Quick check

  1. You need an analyst to read a BigQuery dataset but never delete or edit it. Which predefined role, and at what scope should you bind it?
  2. True or false: a downloaded service-account key is the recommended way to authenticate a Cloud Run service to Cloud Storage.
  3. A binding you removed at the project level didn’t change someone’s access. What’s the most likely reason?
  4. What single attribute makes a GitHub-Actions Workload Identity Federation provider safe rather than open to any repo?
  5. You want to forbid everyone (including owners) from deleting a critical bucket. Allow policy or deny policy?

Answers

  1. roles/bigquery.dataViewer, bound on that dataset (the lowest scope that works), not on the whole project. It allows reading data/metadata but not editing or deleting.
  2. False. The recommended way is keyless: run Cloud Run as an attached service account so it gets short-lived metadata-server tokens. A downloaded key is the last-resort fallback.
  3. The principal still has the access from an ancestor binding (folder or organization). Effective access is the union of all levels — remove the binding at the level that’s actually granting it.
  4. The attribute condition scoping the provider to a specific repository (e.g. assertion.repository=='kloudvin/app'). Without it, any GitHub repo’s token could impersonate your SA.
  5. A deny policy — it’s evaluated first and beats any allow, so it forbids the action regardless of roles (add a narrow exception group for break-glass if needed).

Glossary

Next steps

You can now design and apply least-privilege IAM, give workloads keyless credentials, and audit grants. Build outward:

GCPIAMService AccountsRolesBindingsLeast PrivilegeWorkload Identity FederationSecurity
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading