Almost every team deploying to Google Cloud from GitHub Actions starts with the same shortcut: create a service account, export a JSON key, paste it into a repository secret, and let the workflow authenticate with it. It works, and it quietly turns an identity problem into a secrets problem. A service account key does not expire unless somebody rotates it, works from any address on the internet, and carries every permission the account holds. Workload Identity Federation removes the key entirely: GitHub mints a signed OIDC token per job, Google Cloud validates it against a trust policy you control, and the job receives credentials that expire with the job. This is the migration we run on client platforms — the trust boundary, the workflow change, the least-privilege design, and the checks that prove the old keys are gone.
Why the JSON key outlives the migration
Keys survive because they are fast to create and invisible in cost. Nothing breaks when one is copied into a second repository, a laptop shell, or a CI system nobody reviewed last quarter. The failure mode is rarely dramatic: it is four copies of one credential, three of which no longer have an owner, attached to a service account whose real access is wider than the workflow needs. Google's own write-up of keyless authentication draws the contrast sharply — a key is either accessible or inaccessible, while federation decides per request which repositories, branches, or environments may authenticate, and issues short-lived credentials instead of a permanent one.
Key creation can also be blocked outright. Organizations that apply the service account key creation constraint cannot issue new keys at all, which turns every workflow still holding one into a tracked exception. If repository secrets are not yet part of your inventory of standing access, they should be — the CI/CD pipeline hardening checklist covers the surrounding controls, from least-privilege deploy identities to artifact provenance.
How the token exchange actually works
When a job runs, GitHub's OIDC provider issues a JSON Web Token scoped to that job. It carries a fixed issuer, an audience, and a subject claim assembled from repository metadata — for a push to main, something close to repo:ORG/REPO:ref:refs/heads/main. Google Cloud's Security Token Service checks the signature, issuer, and audience against the workload identity provider you configured, evaluates your condition expression, and returns a short-lived federated credential. Two models are available: grant IAM roles directly to the federated principal, or let it impersonate a service account via the Workload Identity User role. For GKE rollouts and Terraform runs, impersonation is the pragmatic option because most tooling expects a service account principal.
The claims are the access-control surface
The attribute condition is the part teams get wrong. Mapping the subject claim and stopping there trusts any workflow in your organization that can request a token — including a job in a repository you forgot exists. Pin the condition to the organization, the repository, and the ref or environment allowed to deploy, and keep it in version control beside the provider definition. GitHub's documentation is explicit that a trust policy must define at least one condition; a provider without one is an open door, not a default.
Building the trust boundary
A handful of commands is enough to stand the boundary up by hand. Everything below belongs in Terraform afterwards, so the condition and the IAM binding cannot drift apart.
- Create the pool: gcloud iam workload-identity-pools create github --location=global --display-name='GitHub Actions'
- Create the provider with a mapping and a repository condition: gcloud iam workload-identity-pools providers create-oidc github-actions --location=global --workload-identity-pool=github --issuer-uri='https://token.actions.githubusercontent.com/' --attribute-mapping='google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.repository_owner=assertion.repository_owner,attribute.ref=assertion.ref' --attribute-condition="assertion.repository_owner=='ORG' && assertion.repository=='ORG/REPO'"
- Let the federated identity impersonate the deploy account: gcloud iam service-accounts add-iam-policy-binding deploy@PROJECT_ID.iam.gserviceaccount.com --role=roles/iam.workloadIdentityUser --member='principalSet://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/github/attribute.repository/ORG/REPO'
- Read the condition back before trusting it: gcloud iam workload-identity-pools providers describe github-actions --location=global --workload-identity-pool=github --format='value(attributeCondition)'
Two details matter. The principal set references the attribute you mapped, so a mismatch between mapping and binding produces a silent permission denied at deploy time rather than a configuration error. And the pool can live in a separate, tightly controlled project — the pattern Google's guidance recommends, and the reason a compromised workload project cannot rewrite the policy that governs it.
Wiring the workflow without long-lived secrets
On the GitHub side the change is small: the job needs permission to request a token, and one step that exchanges it for Google credentials. The official auth action performs the exchange and writes an Application Default Credentials file that gcloud, Terraform, and Google client libraries pick up automatically.
- Grant id-token: write and contents: read at the job level, and nothing the deploy does not use.
- Run the checkout step before the auth step, because the credentials file is written relative to the workspace.
- Point google-github-actions/auth@v3 at projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/github/providers/github-actions and at the deploy service account.
- Add gha-creds-*.json to .gitignore and .dockerignore so a generated credentials file never reaches an image or a release artifact.
Pin that action to a commit SHA where a compromised upstream tag would be expensive, because a mutable tag is a standing grant to mint your deploy credentials. The same reasoning applies to every third-party action, and the Terraform and IaC scanning guide shows how to make that check part of the build rather than a review comment somebody ignores.
Scope the deploy identity, not just its login
Federation changes how the pipeline authenticates, not what it may do, and the most common mistake is reusing the old service account unchanged. An account that pushed images for two years through a static key accumulated permissions because nothing ever pushed back.
One identity per environment and target
Give development, staging, and production their own service accounts, and each its own provider or condition. A condition on the environment claim, combined with a GitHub environment that requires reviewer approval, means a pull-request build cannot obtain production credentials even if somebody edits the workflow. Then reduce each identity to the roles the deploy actually calls: Container Developer and Artifact Registry Writer for a GKE rollout, granted on the specific project or namespace rather than the whole organization.
Workloads inside the cluster need a separate mechanism. Pod-level access to Google APIs uses Kubernetes service account federation, covered in our guide to GKE Workload Identity for pods. Keeping the two identities apart means a stolen deploy token still cannot read production data.
Proving the keys are gone
Do not call the migration finished when the workflow goes green. Confirm that the identities your pipelines use have no user-managed keys left:
- gcloud iam service-accounts keys list --iam-account=deploy@PROJECT_ID.iam.gserviceaccount.com --managed-by=user should return nothing. Every row it does return carries the full authority of that service account.
- gcloud logging read "protoPayload.methodName='google.iam.admin.v1.CreateServiceAccountKey'" --limit=20 --format='table(timestamp,protoPayload.authenticationInfo.principalEmail)' shows who created a key recently, which is usually more informative than the key list itself.
- Add a log-based alert on that method so a key created by anyone, including an engineer debugging at three in the morning, reaches the platform team instead of sitting in an audit log until the next review.
Then apply the organization policy that disables service account key creation. That control turns "we do not use keys" from a convention into a fact, and stops a well-meaning engineer from reintroducing one mid-incident.
A migration checklist that can be rolled back
This is the order we run it in, with a working rollback at every step.
- Inventory every repository secret and CI variable holding a service account key, and record which account it maps to.
- Create the pool and one provider per repository, with a condition naming organization, repository, and allowed ref or environment.
- Create a dedicated deploy service account per environment, granted roles on the target project or namespace only.
- Move one non-production workflow to the auth action, keeping the old secret as a fallback for a single release cycle.
- Delete the repository secret and revoke the key once the new path has run cleanly through a defined observation window.
- Verify with the key listing and the audit query that no user-managed keys remain on any deploy identity.
- Enable the constraint that blocks key creation, then repeat the sequence for the next pipeline.
If your pipelines still authenticate with an exported JSON key and the migration keeps sliding behind feature work, Secpros can review the trust policy, the workflow permissions, and the effective access of each deploy identity, then return a short prioritized audit plan you can schedule without a deployment freeze.