Service Accounts and Keys
A service account is GCP's identity for non-human workloads, and the thing that trips newcomers is that it is two things at once. It is a principal you grant roles to — it appears as a member serviceAccount:event-processor@hatch-app-prod.iam.gserviceaccount.com in other resources' policies. And it is a resource that itself has an IAM policy, controlling who may use or impersonate it. Those are two separate sets of bindings on two different objects, and conflating them is the source of half the confusion in this topic.
The over-privileged defaults are the historical trap. A project's default Compute Engine and default App Engine service account used to be auto-granted roles/editor on the whole project — but for organizations created on or after May 3, 2024, the iam.automaticIamGrantsForDefaultServiceAccounts org policy is enforced by default, so that automatic Editor grant no longer happens; older orgs may still carry it. And service-account keys are downloadable, long-lived secrets with no expiry — they should be your last resort, never your first. Get the two-natures idea and the defaults right, and service accounts stop being mysterious.
An Identity and a Resource
google_service_account creates the account. As an identity, it is a member in other resources' policies — you grant it roles/storage.objectAdmin on the hatch-events-raw bucket so the workload can read and write objects. As a resource, it has its own IAM policy that says who may impersonate it or attach it to a workload. These are two distinct sets of bindings, and people regularly grant on the wrong one: trying to give the SA a bucket role by editing the SA's own policy, or vice versa.
Keeping the two surfaces straight is the whole discipline. "Grant the SA a role" means add the SA as a member on some other resource. "Grant a role on the SA" means edit the SA's own policy to say who may act through it. The configuration below creates the account and then grants it — as an identity — a scoped role on a bucket.
resource "google_service_account" "event_processor" { project = "hatch-app-prod" account_id = "event-processor" display_name = "Event pipeline processor" } resource "google_storage_bucket_iam_member" "raw_rw" { bucket = "hatch-events-raw" role = "roles/storage.objectAdmin" member = "serviceAccount:${google_service_account.event_processor.email}" }
The Email as the Member
The SA's email — here event-processor@hatch-app-prod.iam.gserviceaccount.com — is how you reference it in any binding, prefixed with serviceAccount:. Terraform reads it straight off the google_service_account resource, as in google_service_account.event_processor.email, so you never hardcode the string. Wiring the identity from the resource means a renamed project or a copy-paste slip cannot silently point a binding at the wrong account.
Hardcoding the literal email is the small mistake that causes the large outage. The string looks stable, but the moment a project is renamed or an account recreated under a different ID, the literal is wrong and the binding grants to nothing — or worse, to the wrong identity. Reference the attribute, always.
The Over-Privileged Default Service Accounts
Most projects get two default service accounts — the default Compute Engine SA, <project-number>-compute@developer.gserviceaccount.com, and the App Engine default SA. Historically each was auto-bound roles/editor on the entire project, and where that grant is present anything running on a default VM inherits near-total project access, because the VM runs as that SA and that SA is an editor — the textbook lateral-movement path: pop the VM, inherit project-wide edit. For organizations created on or after May 3, 2024 the iam.automaticIamGrantsForDefaultServiceAccounts constraint is enforced by default, so the automatic Editor grant no longer happens; on older orgs it persists until you remove it or enforce the constraint yourself.
The fix is to create purpose-built service accounts and stop running on the defaults. Remove or replace the default SAs' roles/editor grant, or — better — use an org policy to disable default SA creation entirely, so the dangerous default never exists. A workload should run as an account scoped to exactly what it touches, not as a project-wide editor that happened to come free with the project.
Keys Are a Last Resort
google_service_account_key mints a downloadable JSON private key — a long-lived credential with no expiry. It lands in Terraform state, it leaks from laptops and CI secret stores, and it never rotates itself. It is the credential most likely to end up in a breach report. Prefer impersonation, covered in the next topic, or Workload Identity Federation, and create a key only for the rare external system that genuinely cannot do either.
If you must mint one, treat it as radioactive: it now exists in state that the whole team can read, so the team can read the key. The configuration below shows what to avoid by default — a key resource whose private material is written to state. For anything running inside GCP, you never need this; the workload assumes its attached SA without a key at all.
resource "google_service_account_key" "legacy_export" { service_account_id = google_service_account.event_processor.name # the JSON private key lands in Terraform state — readable by the whole team, # long-lived, never auto-rotated. Prefer impersonation or WIF instead. }
One Workload, One Service Account
Give event-processor its own service account scoped to exactly the buckets, topics, and datasets it touches, instead of reusing a shared or default SA across unrelated workloads. The payoff is blast-radius containment: a compromise of one workload is limited to that one workload's permissions, not the union of everything a shared account could reach. One workload, one SA, scoped tight — it is the cheapest security control in the chapter and the one most often skipped for convenience.
The SA as an identity — you grant it a role on some other resource (roles/storage.objectAdmin on hatch-events-raw). The binding lives on the bucket; the SA is the member. This is how the workload gets its permissions.
The SA as a resource — you grant someone a role on it (roles/iam.serviceAccountUser to a deployer). The binding lives on the SA's own policy and controls who may use or impersonate it. Mixing these two up is the most common service-account error.
- Running production workloads on the default Compute Engine SA where it still carries the legacy
roles/editorgrant — anything that pops the VM gets project-wide edit, the textbook lateral-movement path. - Creating a
google_service_account_keyand committing the JSON, or leaving it in Terraform state read by the whole team — the key is long-lived, unscoped in time, and the credential most likely to leak. - Confusing the SA's two IAM surfaces: granting
roles/iam.serviceAccountUseron the SA when you meant to grant the SA a role on a bucket, or the reverse — they are different policies on different resources. - Sharing one service account across
event-processorand unrelated workloads — a single compromise now spans every permission the shared SA holds. - Hardcoding the SA email string instead of referencing
google_service_account.x.email— a renamed project or a copy-paste slip silently points bindings at the wrong identity.
- Create a dedicated
google_service_accountper workload, scoped to exactly the resources it needs, and never run on the default Compute or App Engine SAs. - Remove or replace the default SAs'
roles/editorgrant, or use an org policy to disable default SA creation entirely. - Avoid
google_service_account_key; use impersonation or Workload Identity Federation, and only mint a key for a system that genuinely cannot federate. - Reference the SA by
google_service_account.x.emailin every binding, prefixedserviceAccount:, so the identity is wired from the resource, not a literal string. - Grant the SA its roles on the resources it touches, and keep the SA's own policy (who may use it) as a separate, deliberately small set of bindings.
Knowledge Check
In what two senses is a service account "two things at once"?
- It is a principal you grant roles to on other resources, and a resource with its own policy controlling who may use or impersonate it
- It is simultaneously registered as both a human user and a security group inside the Cloud Identity directory that backs the organization
- It is simultaneously a predefined role and a custom role
- It is a project and a folder in the resource hierarchy at the same time
Why are the default Compute Engine and App Engine service accounts dangerous where the legacy grant is present?
- Historically they were auto-granted
roles/editoron the whole project, so any workload running as one inherits near-total project access wherever that grant still exists - They are created with no permissions whatsoever, so every workload running as the default Compute Engine or App Engine service account silently fails its API calls until an operator grants it roles by hand
- They can only be used by human users, not workloads
- They expire after 90 days and break running services
Why is google_service_account_key a last resort?
- It is a downloadable, long-lived credential with no expiry that lands in state and never auto-rotates — the credential most likely to leak
- It carries a per-key surcharge and is billed by the hour for the entire time the JSON key exists, making it the most expensive way to authenticate
- It can only grant
roles/viewer, so it is too weak for real workloads - It rotates so aggressively that workloads constantly lose access
What is the difference between granting the SA a role and granting someone a role on the SA?
- Granting the SA a role adds it as a member on another resource; granting a role on the SA edits the SA's own policy to say who may use or impersonate it
- They are the same underlying operation written two different ways, and both produce the exact same binding on the exact same IAM policy
- Granting a role on the SA is only possible by clicking through the GCP Console by hand, and can never be expressed in any Terraform configuration resource block
- Granting the SA a role requires a key, while granting a role on the SA does not
You got correct