Keyless CI with WIF
When the pipeline lives in GitHub rather than Cloud Build, Workload Identity Federation — set up in Chapter 2 — is what lets the Actions job authenticate to GCP with no JSON key anywhere. The runner mints its own OIDC token, the google-github-actions/auth action exchanges it through the workload identity provider for a short-lived GCP token, and Terraform then runs as the bound terraform-ci service account: plan on the pull request, apply on merge.
The thing to internalize is that no long-lived credential ever exists in this flow. There is no key in a secret, no key on disk, nothing to leak or rotate. Each run mints a fresh token that lives for minutes and is scoped to exactly the repo and branch the trust was pinned to. That is the entire reason to prefer WIF over a stored key, and the rest of this topic is how the pieces fit.
The Full Keyless Flow
GitHub issues the job an OIDC token carrying claims about where it came from — the repository, the branch, the environment. The google-github-actions/auth step posts that token to the workload identity provider; GCP validates it against the provider's attribute condition and, if it matches, returns a short-lived access token for the bound service account. Terraform reads that token through Application Default Credentials. At no point does a key file exist on the runner or in GitHub.
The workflow below is the plan-on-PR half. The id-token: write permission is what lets the runner receive an OIDC token at all, and the auth step names the provider and the SA to impersonate.
permissions: contents: read id-token: write # required to get an OIDC token at all jobs: plan: runs-on: ubuntu-latest steps: - uses: google-github-actions/auth@v3 with: workload_identity_provider: projects/.../providers/github service_account: terraform-plan@hatch-admin.iam.gserviceaccount.com - run: terraform init && terraform plan -no-color
On merge to main, a second workflow re-runs the same auth step against a write-capable SA and runs apply instead of plan. The token is fresh each time; nothing is stored between runs.
The Pool, Provider, and Binding Recap
The trust set up in Chapter 2 is what makes the exchange safe. The workload identity provider pins the hatch-io/infrastructure repo through its attribute condition, and the federated identity holds roles/iam.workloadIdentityUser on the terraform-ci service account so it is allowed to impersonate it. The workflow here is the consumer of that trust, not the place it is defined — the pool, the provider, and the binding already exist.
It is worth keeping that boundary clear. If a run fails to authenticate, the fix is almost always in the provider's attribute condition or the workloadIdentityUser binding, not in the workflow YAML. The YAML only names which provider and which SA to use.
The plan-on-PR Job
A pull-request-triggered workflow runs auth, setup-terraform, init, and plan, then posts the plan as a pull-request comment so reviewers see the diff inline with the code change. This job needs no apply permission at all, so it should map to a read-mostly service account — one that can read state and plan but cannot mutate prod. A reviewer reads the plan; nothing has changed yet.
Pinning the plan job to a low-privilege SA is the safety net that makes a malicious pull request harmless. Even if someone opens a PR that tries to do damage, the plan job that runs against it cannot apply anything, because the identity it authenticates as has no write path.
The apply-on-merge Job
A push-to-main workflow re-authenticates and runs apply as the SA with write permissions, gated behind a GitHub environment with required reviewers so a merge does not silently mutate prod. Use a different, write-capable SA here than the plan job's read-mostly one. Binding the same write-capable SA to both jobs means a pull request can apply, which puts you right back to a PR mutating prod before review.
The GitHub environment protection is the human gate. Without it, every merge applies to prod the instant it lands, with no approval step — a deploy on every commit to main whether you meant it or not. With required reviewers on the environment, the apply waits for an approval even after the merge.
id-token: write and Pinning the Trust
The workflow must request the id-token: write permission for the runner to receive an OIDC token at all. Omit it and the auth step fails with no token to exchange — this is the single most common first-run error, and the message is opaque enough that it costs people an afternoon. It is one line in the workflow's permissions block.
Pin the trust tightly on the GCP side. The attribute condition ties the token to assertion.repository == 'hatch-io/infrastructure', and for the apply path it should also require the protected branch or environment. Pin the branch but forget the repository and a fork with a matching branch name can authenticate; pin both and only the real repo's protected branch can mint an apply-capable token.
Cloud Build — the runner lives inside GCP and authenticates through its attached service account, so there is no token exchange at all; credentials are simply present through ADC. Choose it when you want the runner in-cloud and GCP-native.
GitHub Actions + WIF — the runner lives outside GCP and exchanges GitHub's OIDC token for a short-lived GCP token through the workload identity provider. Choose it when the team already lives in GitHub and wants CI sitting beside the code, accepting the extra trust setup the exchange requires.
- Forgetting
permissions: id-token: writein the workflow and getting an auth failure because GitHub never issued the OIDC token to exchange. - Storing a service-account JSON key in GitHub secrets "to get it working" and never removing it — the keyless setup exists precisely to avoid this, and the key is now a leaked-credential liability.
- Binding the same write-capable SA to both the PR and merge jobs — a pull request can then apply, so a malicious or careless PR mutates prod before review.
- Pinning the attribute condition to a branch but not the repository, so a fork with a matching branch name can authenticate and mint a token.
- Running
applyon merge with no GitHub environment protection — every merge mutates prod instantly with no approval gate.
- Request
id-token: writein the workflow and authenticate withgoogle-github-actions/authagainst the workload identity provider — never add a JSON key. - Use a read-mostly SA for the plan-on-PR job and a write-capable SA only for the apply-on-merge job.
- Gate the apply-on-merge job behind a GitHub environment with required reviewers so prod changes need an approval.
- Constrain the attribute condition to
hatch-io/infrastructureand the protected branch, so forks and feature branches cannot mint apply tokens. - Post the plan as a pull-request comment so reviewers read the exact diff alongside the code change.
Knowledge Check
In the keyless WIF flow, how does the GitHub Actions job end up authenticated to GCP?
- GitHub issues an OIDC token, the auth action exchanges it through the workload identity provider, and GCP returns a short-lived token for the bound SA
- A long-lived JSON service-account key stored in GitHub secrets is base64-decoded at job start and written to the runner's local disk for gcloud to read
- The runner reads a token straight from the GCP instance metadata server, since hosted Actions runners live inside GCP
- GitHub securely forwards the developer's own personal gcloud session and refresh token into the running workflow
A first WIF run fails at the auth step with no token to exchange. What is the most likely cause?
- The workflow is missing permissions: id-token: write, so GitHub never issued an OIDC token
- The terraform-ci service account was deleted from the project before the workflow was ever triggered to run
- The GCS state bucket is missing its lock object, so the backend cannot acquire a lock
- The runner ran out of disk space while downloading the large google provider plugin
Why should the plan-on-PR job and the apply-on-merge job use different service accounts?
- If both used the same write-capable SA, a pull request could apply, so a careless or malicious PR would mutate prod before review
- GitHub charges a recurring monthly fee per impersonated service account, so two narrowly-scoped SAs end up costing less than one broad one
- A single service account cannot authenticate more than once from the same repository within a 24-hour window
- The plan job still needs a stored JSON key file, while only the apply job is able to use keyless WIF
What must the workload identity provider's attribute condition pin to keep a fork from minting an apply token?
- Both the repository (hatch-io/infrastructure) and the protected branch, since pinning the branch alone lets a fork with a matching branch authenticate
- Only the branch name on its own, since a branch reference like refs/heads/main is unique across all of GitHub
- The hosted runner's outbound IP address range, which GitHub guarantees stays stable for every workflow run
- The exact commit SHA of the head ref all by itself, which the workflow deterministically recomputes and re-pins as the only condition on every single run
You got correct