Service accounts & impersonation
Killing keys and scoping the escalation path.
Someone on your team pushes a file called sa-key.json to a public GitHub repo. A bot scrapes it within minutes, and by the afternoon a stranger in another country is running crypto miners billed to your project. This happens constantly, and the cause barely varies: a downloaded service-account key. That file is a house key that never changes the lock and never expires. Copy it once and the copy works forever.
Back up to what a service account actually is. Your programs, your build pipelines and your virtual machines are not people, but Google still makes them prove who they are before it will hand over the contents of a storage bucket or let them write a database row. A service account (SA), the identity a piece of software signs in as, is how that happens. Treat it as a work badge issued to a job rather than to a person. The nightly export job carries one badge; the web server carries another. Each badge has an email-shaped name like [email protected], and you attach permissions to it (IAM roles, from Identity and Access Management, Google's system for deciding who may call which API, the door software knocks on instead of a web page) the same way you would for a human user.
There are two ways to put that badge in a machine's hand, and the gap between them is the whole lesson. Option one: photocopy the badge and let the contractor keep the copy for life. That is a downloaded key file. Option two: have them sign the real badge out at the front desk for one errand, with their name in the logbook, and the badge stops working an hour later. That is impersonation. The first is a liability you carry until someone finds it. The second expires on its own and leaves a trail.
Make the badge boring before you hand it out
Before any of the borrowing matters, keep the badge itself dull. Build a service account for one job and give it only the roles that job needs, in the one project it needs them in. A reporting account that reads tables should read tables and nothing else. Then the day it gets borrowed by the wrong person, the worst outcome is 'someone read a few BigQuery tables', not 'someone deleted production'. Least privilege on the account is the ceiling on the damage from every mistake that comes after it.
$ gcloud iam service-accounts create reporter \--display-name="Nightly BigQuery export" \--project=payments-prodCreated service account [reporter].$ gcloud projects add-iam-policy-binding payments-prod \--member="serviceAccount:[email protected]" \--role="roles/bigquery.dataViewer"Updated IAM policy for project [payments-prod].bindings:- members:- serviceAccount:[email protected]role: roles/bigquery.dataVieweretag: BwYX3nL2p9c=version: 1
Now the part most teams have to unlearn. A downloaded service-account key is a small text file (JSON, a plain-text format for structured data) with a private key inside, and it is a bearer credential: whoever holds the file gets treated as the service account, no questions asked. No password prompt. No second factor. No expiry date. Commit it to git once and it stays burned even after you delete the commit, because the repository history keeps a copy. The safest number of key files in your whole organization is zero, and Google Cloud lets you make that a rule with an organization policy (a constraint set high in the resource hierarchy that every project underneath inherits), so nobody can create one even by accident.
# key-policy.yamlname: organizations/561209481324/policies/iam.disableServiceAccountKeyCreationspec:rules:- enforce: true$ gcloud org-policies set-policy key-policy.yamlname: organizations/561209481324/policies/iam.disableServiceAccountKeyCreationspec:etag: CNiP2r0GENDp3IsBrules:- enforce: trueupdateTime: '2026-07-16T02:01:14Z'# Someone tries to export a key anyway:$ gcloud iam service-accounts keys create key.json \ERROR: (gcloud.iam.service-accounts.keys.create) FAILED_PRECONDITION: Key creationis not allowed on this service account because it violates constraintconstraints/iam.disableServiceAccountKeyCreation on the resource hierarchy.
With that constraint enforced, the leak path is shut at the door. A project Owner cannot create a key. A well-meaning deploy script that used to export one now fails loudly instead of quietly producing a time bomb somebody finds three years later. So how does anyone act as the service account when there is no file to hand out? They borrow it.
Impersonation: sign the badge out, for one job, in the logbook
Impersonation means asking Google for a fresh, short-lived token that lets you act as a service account without ever touching a key file. The permission that allows it is roles/iam.serviceAccountTokenCreator, and you grant it on one named service account. Once someone holds it, they add a single flag to almost any gcloud command, --impersonate-service-account, and Google's IAM Credentials API (the service software calls when it needs a token) hands back an access token good for about an hour by default. That token is the badge signed out at the front desk. It covers the one job and then dies by itself. Nothing to rotate, nothing to clean up.
$ gcloud iam service-accounts add-iam-policy-binding \--member="user:[email protected]" \--role="roles/iam.serviceAccountTokenCreator"Updated IAM policy for serviceAccount [[email protected]].bindings:- members:- user:[email protected]role: roles/iam.serviceAccountTokenCreator# Dana never downloads a key. She borrows the SA for one command:$ gcloud storage ls gs://payments-prod-exports \--impersonate-service-account=reporter@payments-prod.iam.gserviceaccount.comWARNING: This command is using service account impersonation. All API calls willbe executed as [[email protected]].gs://payments-prod-exports/2026-07-15/gs://payments-prod-exports/2026-07-16/# Or mint just the raw token (valid ~1 hour) for a script or another tool:$ gcloud auth print-access-token \--impersonate-service-account=reporter@payments-prod.iam.gserviceaccount.comya29.c.c0ASRK0GY0m...QpZ3Xv9
Behind that flag sits a GenerateAccessToken call to the IAM Credentials API. Google checks that the caller really does hold Token Creator on the target account, mints the token, and the command runs as the service account. The security payoff is in what gets written down. The audit log captures both identities in one entry: the human email that made the request, and the service account that was borrowed. Nobody can quietly act as the account and then blame 'the machine', because the log always names the person, or the CI runner (the automated server that runs your build and deploy pipeline), that did the borrowing.
Where this quietly turns into an escalation path
Here is why 'on one named service account' carries so much weight. Grant Token Creator at the project level instead and you have handed out the master key to the whole badge cabinet. That principal can now borrow every service account in the project, including any carrying Owner or Editor. A user with almost no permissions of their own is one command away from acting as your most powerful identity. Google's legacy default Compute Engine and App Engine service accounts made this worse for years by shipping with the Editor role attached, so anything running as a default account could change almost anything. Replace those defaults with purpose-built, least-privilege accounts, and keep Token Creator pinned to individual accounts.
There is a quieter version of the same problem. Impersonation chains. If account A can borrow B, and B can borrow C, then whoever can act as A can walk that line all the way to C, and the --impersonate-service-account flag even accepts a comma-separated delegation chain to do it in a single call. Reviewing direct grants is not enough. You have to follow the whole graph of who-can-borrow-whom. What saves you is that every borrow is recorded. Turn on Data Access audit logs for the IAM Credentials API and each token-minting call lands in the log with the caller's name attached, so you can chase the entire chain from the log itself.
$ gcloud logging read \'protoPayload.methodName="GenerateAccessToken"' \--project=payments-prod --limit=1 --format=json[{"protoPayload": {"authenticationInfo": { "principalEmail": "[email protected]" },"methodName": "GenerateAccessToken","request": {"name": "projects/-/serviceAccounts/[email protected]"},"serviceName": "iamcredentials.googleapis.com"},"resource": { "type": "service_account" },"timestamp": "2026-07-16T02:00:07Z"}]
A default compute service account carrying Editor is a gift to whoever gets a shell on that virtual machine. Give every workload its own account, grant it the roles that workload needs and no others, and attach it at create time. Take the identity from the metadata server, or from Workload Identity Federation (which lets an outside system, say a GitHub Actions job, trade its own signed identity for a Google token), rather than from a JSON file on disk. When a person or a pipeline has to act as that account, put roles/iam.serviceAccountTokenCreator on a short list of principals and borrow the account for one command, not for a week.
Audit for key files the way you audit for Owner. Walk the projects on a schedule and list the keys. Anything user-managed and older than your rotation window gets revoked. Anything that still genuinely needs a key gets a ticket explaining why federation and attachment both fail for that case, and those tickets should be rare enough that a security engineer still reads them properly.
Try this
Take one project. List its service accounts, confirm that key creation is blocked by org policy, then get yourself an hour of access by impersonation instead of downloading anything.
gcloud iam service-accounts list --project=payments-prodgcloud iam service-accounts keys list \--project=payments-prodgcloud org-policies describe constraints/iam.disableServiceAccountKeyCreation \--project=payments-prod# short-lived access without a key file:gcloud auth print-access-token \--impersonate-service-account=reporter@payments-prod.iam.gserviceaccount.com
EMAIL DISABLED[email protected] False[email protected] FalseListed 0 items.constraint: constraints/iam.disableServiceAccountKeyCreationlistPolicy:allValues: DENYya29.c.b0Aaekm1... # ~1 hour token; no sa-key.json on disk
Takeaway
A service-account key is a password that never expires and never asks who is holding it. Attach identities to workloads, borrow them by impersonation when a human or a pipeline needs one, block key creation with org policy, and treat every user-managed key still sitting in your estate as an incident that has not been reported yet.
Next you will fence the network those workloads sit on: VPC (virtual private cloud, the private network your machines live in) firewall rules, private Google access, and network policy set at the org level, so a stolen token has fewer doors left to try.
Short-lived, logged identities settle the question of who is acting on a resource. They say nothing about where the call is allowed to come from. A borrowed badge does an attacker no good if they cannot get near the building, and deciding who can reach the door at all is the job of VPC firewall rules, the network walls around your cloud, plus private access. That is where we go next.