Back to blog
7 min

Kill Your Service Account Keys: Workload Identity Federation on GCP in 2026

Long-lived service account keys are a breach waiting to happen. A hands-on 2026 guide to Workload Identity Federation on GCP: how the keyless token exchange works, wiring GitHub Actions with zero secrets, the attribute-condition trap that opens your project to any repo, and migrating off keys without downtime.

Part ofSecurity →
workload-identity-federationservice-account-keysgcpcloud-securitygithub-actionsiamgoogle-clouddevsecops
Contents

Every GCP breach story has the same opening scene. A sa-key.json file sits in a CI variable, a pinned Slack message, and someone’s ~/Downloads since 2023. It has one property that should terrify you: it never expires. Whoever holds that file is your service account — with every role you ever granted it — until the day someone remembers to rotate it. Nobody remembers.

Long-lived service account keys are the single most common way a Google Cloud project gets fully compromised. Not a clever exploit — a leaked JSON file. In 2026 you do not need them for almost anything, and this is the field guide to removing them: how the keyless exchange actually works, how to wire GitHub Actions with zero secrets, the one line of config that decides whether your setup is safe, and how to migrate without breaking a single pipeline.

Kill your service account keys — Workload Identity Federation on Google Cloud.

Why a Long-Lived Key Is a 2026 Liability

A service account key is a static credential with three fatal traits:

  • It never expires. A password has a rotation policy; a key has none. It is valid until manually revoked.
  • It travels. JSON is copy-paste. It ends up in Git history, CI logs, laptops, and screenshots — each a new place it can leak.
  • It hides who used it. Audit logs show the service account acting, never the human or machine that presented the key. After a leak, you cannot tell attacker traffic from legitimate traffic.

One key in a public repo equals full access to whatever that account can touch — often more than anyone remembers granting. The fix is not “rotate more often.” It is to stop the key from existing.

What Workload Identity Federation Actually Is

Here is the doc-speak translation. Your external system already has an identity it can prove: GitHub Actions issues every workflow run a signed OIDC token, AWS signs its instance identity, an on-prem box can run any OIDC provider. Workload Identity Federation teaches Google Cloud to trust that token and swap it for a short-lived GCP access token. No key is minted. No secret leaves Google. Nothing static exists to leak.

Three pieces do the work:

  • Workload Identity Pool — a trust boundary that holds one or more external identity sources.
  • Provider — the specific issuer you trust (GitHub’s OIDC endpoint, AWS, an OIDC URL), plus how to read its claims.
  • Attribute mapping + condition — how the external token’s fields (repository, repository_owner, sub) map to Google attributes, and which ones are allowed to exchange at all.

The flow, end to end:

How a keyless login works: your workload presents an OIDC token, the pool and provider check the claims, Google STS exchanges it for a short-lived token, and your service account is impersonated — no key ever created.

The external workload presents its OIDC token, the provider validates and maps the claims, Google’s Security Token Service (STS) returns a short-lived token, and that token impersonates a service account limited to exactly the roles you granted. When the job ends, the token dies. There is nothing to rotate, because there is nothing to store.

Hands-On: GitHub Actions to GCP With Zero Keys

The most common ask is deploying from GitHub without a GCP_SA_KEY secret. Here is the whole setup.

First, create the pool and a GitHub provider:

Terminal window
# One pool per trust boundary (e.g. all CI)
gcloud iam workload-identity-pools create github-pool \
--location=global \
--display-name="GitHub Actions"
# A provider that trusts GitHub's OIDC issuer
gcloud iam workload-identity-pools providers create-oidc github-provider \
--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.repository_owner=assertion.repository_owner" \
--attribute-condition="assertion.repository_owner == 'your-org'"

That last flag is the one that matters. Hold that thought — we come back to it in the next section, because getting it wrong is how people hand their project to strangers.

Next, let a specific repo impersonate a deploy service account:

Terminal window
PROJECT_NUMBER=$(gcloud projects describe my-project --format='value(projectNumber)')
gcloud iam service-accounts add-iam-policy-binding \
deployer@my-project.iam.gserviceaccount.com \
--role=roles/iam.workloadIdentityUser \
--member="principalSet://iam.googleapis.com/projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/github-pool/attribute.repository/your-org/my-app"

Finally, the workflow — note there is no key anywhere:

permissions:
contents: read
id-token: write # lets the runner request an OIDC token
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- id: auth
uses: google-github-actions/auth@v2
with:
project_id: my-project
workload_identity_provider: projects/${{ env.PROJECT_NUMBER }}/locations/global/workloadIdentityPools/github-pool/providers/github-provider
service_account: deployer@my-project.iam.gserviceaccount.com
- run: gcloud run deploy my-app --source .

No secret to leak, no key to rotate. The runner proves who it is, Google hands back a token that expires in minutes, and the deploy runs.

The Trap Everyone Falls Into: attribute_condition

This is the part the quickstarts gloss over, and it is the reason to read this article.

When you create the provider, the attribute_condition decides which external identities are even allowed to exchange a token. Leave it off, and you have told Google to trust the entire issuer. For GitHub, the issuer is token.actions.githubusercontent.com — which signs tokens for every public repository on the planet. Any stranger can fork a workflow, request a token, and impersonate your service account.

Danger versus safe: without an attribute_condition any GitHub repo can assume your identity; with assertion.repository_owner locked to your org, only your repos can exchange a token.

The dangerous version — no condition:

Terminal window
# DANGER: trusts every repo GitHub will ever sign a token for
--issuer-uri="https://token.actions.githubusercontent.com" \
--attribute-mapping="google.subject=assertion.sub"
# (no --attribute-condition)

The safe version — locked to your org, then scoped to a repo on the binding:

Terminal window
# SAFE: only tokens from repos you own can exchange at all
--attribute-condition="assertion.repository_owner == 'your-org'"

Two independent gates protect you: the condition on the provider decides who may exchange a token, and the principalSet on the IAM binding decides which of those may impersonate a given service account. Set both. A provider without a condition is the number-one Workload Identity Federation misconfiguration, and it is exactly the kind of thing an attacker scans for.

Migrating Off Existing Keys Without Downtime

You cannot delete keys you cannot see. Start with an inventory across every service account:

Terminal window
for sa in $(gcloud iam service-accounts list --format='value(email)'); do
gcloud iam service-accounts keys list --iam-account="$sa" \
--managed-by=user --format="table(name, validAfterTime)"
done

--managed-by=user matters: it filters out Google-managed keys (which are fine and rotate themselves) so you only see the user-created ones you actually need to kill.

Then migrate one workload at a time, never big-bang:

  1. Stand up federation in parallel. Create the pool, provider, and binding. The old key still works — nothing is broken yet.
  2. Switch one pipeline to keyless auth and confirm it deploys.
  3. Disable, do not delete, the key. Watch audit logs for anything still using it:
Terminal window
gcloud logging read \
'protoPayload.authenticationInfo.principalEmail="deployer@my-project.iam.gserviceaccount.com"
AND protoPayload.metadata.@type:"ServiceAccountKey"' \
--freshness=7d --limit=20
  1. Delete once quiet. If nothing has used the key for a full cycle, remove it.

Disable-before-delete gives you a free rollback: if something you forgot still needs the key, re-enable it in seconds instead of firefighting a production outage.

Make Keys Impossible to Recreate

Removing keys is worthless if someone creates a new one next sprint. Close the door at the organization level:

Terminal window
gcloud resource-manager org-policies enable-enforce \
iam.disableServiceAccountKeyCreation \
--organization=YOUR_ORG_ID

With that policy on, gcloud iam service-accounts keys create fails for everyone. Pair it with an alert on the CreateServiceAccountKey audit event so that any attempt — say, someone requesting a policy exception — is visible instead of silent. Now “no keys” is an enforced state, not a hope.

When You Still Can’t Go Keyless

Honesty keeps this credible: federation does not cover everything. A legacy SDK with no OIDC support, a vendor integration that only accepts a key file, an air-gapped system that cannot reach Google’s STS — these still exist. For those, minimize the blast radius instead:

  • Scope the key’s service account to the absolute least privilege it can run with.
  • Rotate on a short, automated schedule — a key you must keep is a key you must expire.
  • Store it in Secret Manager, never in a repo, a CI variable, or a shared drive.

A key you are forced to keep should be the rare, documented exception — not the default you never questioned.

The Field Rule

Treat every long-lived service account key as a breach that has not happened yet. Replace it with Workload Identity Federation: an external OIDC token exchanged for a short-lived GCP token, with no secret ever stored. Always set an attribute_condition — without it, any repo can wear your identity. Migrate one workload at a time with disable-before-delete, then enforce disableServiceAccountKeyCreation so keys can never come back. Get that right and the scariest file in your infrastructure simply stops existing.

The field rule: go keyless and stay keyless on Google Cloud.

If you are hardening a GCP estate — or working through the same keyless migration yourself — reach out. I share what I build and secure under #GoogleCloud, #CloudSecurity and #GoogleCloudAmbassador.

Frequently asked questions

What is the difference between Workload Identity Federation and GKE Workload Identity?

They solve the same problem — no keys — in two different places. Workload Identity Federation lets an external system (GitHub Actions, AWS, on-prem, any OIDC provider) trade its own token for a short-lived GCP token. GKE Workload Identity is the in-cluster version: it binds a Kubernetes service account to a Google service account so pods get credentials without a mounted key. Use federation for anything outside GCP, GKE Workload Identity for pods running inside a cluster.

Is Workload Identity Federation free on Google Cloud?

Yes. Workload Identity Pools, providers, and the token exchange itself carry no additional charge — you only pay for the GCP resources your workload actually calls. There is no reason to keep paying the security cost of long-lived keys when the keyless path is free.

How do I secure Workload Identity Federation against token abuse?

Always set an attribute_condition on the provider. Without it, any identity from the external issuer — for GitHub, that means every public repository — can request a token for your project. Lock it to your own org or repo with a condition like assertion.repository_owner == 'your-org', map only the claims you need, and grant the impersonated service account the least privilege it can do the job with.

Can I migrate off service account keys without downtime?

Yes. Run federation in parallel first: set up the pool and provider, switch one workflow to keyless auth, and confirm it works while the old key still exists. Then disable the key (do not delete it yet) and watch the audit logs for any remaining use. Once nothing calls it for a full cycle, delete it and block new keys with the org policy so it can never return.

Was this article helpful?

ENDE