A downloaded service account key is a bearer credential with no expiry, no audience binding, and no idea where it is being used. Workload Identity Federation (WIF) deletes that whole class of risk: your CI system already proves its identity to its own provider with a short-lived OIDC token, and GCP’s Security Token Service (STS) exchanges that token for a federated GCP credential scoped to exactly one repository and branch. This walkthrough builds the GitHub Actions case end to end, then generalizes it to GitLab, Terraform Cloud, and AWS-to-GCP workloads.
1. Why JSON keys are a liability and what federation replaces
A gcloud iam service-accounts keys create JSON file is the single most common credential-leak vector on GCP. The reasons are structural, not operational:
- It does not expire. A key committed to a repo in 2022 still works today unless someone remembers to revoke it.
- It is a static secret you must store, rotate, and inject into every CI runner. Each of those is a place it can leak.
- It carries no context. STS cannot tell whether the holder is your pipeline or an attacker who scraped it from a build log.
Federation replaces the static key with a trust relationship. Instead of “here is a secret only my pipeline knows”, the model becomes “GCP trusts tokens minted by GitHub’s OIDC issuer, but only when the claims inside prove the token came from my-org/my-repo on main”. The credential the pipeline ends up holding is a federated access token that lives for an hour at most.
If your org has set the
iam.disableServiceAccountKeyCreationorg policy constraint (and it should), key creation is already blocked. WIF is then not optional hardening, it is the supported path for CI to authenticate at all.
The moving parts:
| Component | What it is | Lifetime |
|---|---|---|
| Workload identity pool | A container for external identities, scoped to a project | Permanent |
| Pool provider (OIDC) | Trust config for one external issuer (GitHub, GitLab, etc.) | Permanent |
| Attribute mapping | Maps OIDC claims (sub, repository) to Google attributes |
Config |
| Attribute condition | A CEL expression that rejects tokens failing the predicate | Config |
| STS token exchange | The runtime swap of OIDC token for a GCP federated token | ~1 hour |
2. How the STS token exchange actually works
Understanding the flow is what lets you debug it later. End to end:
- GitHub mints a signed OIDC JWT for the running job. Its
issishttps://token.actions.githubusercontent.com, and its claims includesub,repository,ref,actor, andworkflow. - The
google-github-actions/authstep POSTs that JWT to GCP STS (sts.googleapis.com), naming the pool provider as the audience. - STS validates the JWT signature against GitHub’s published JWKS, evaluates your attribute condition, and applies your attribute mapping.
- If everything passes, STS returns a short-lived federated access token representing the external identity (a
principalSet). - Optionally, the step calls IAM Credentials to impersonate a real service account, returning an access token that carries that SA’s roles. This is the credential your
gcloud/Terraform steps use.
There are two ways to consume the result. Service account impersonation (the external identity is granted roles/iam.workloadIdentityUser on a target SA, then impersonates it) is the broadly compatible path and the one I recommend by default. Direct resource access (granting IAM roles to the principalSet directly, no SA) is newer and avoids the SA entirely, but not every Google client library and service honors federated principals yet, so it carries compatibility caveats. We will wire impersonation here and note the direct variant at the end.
3. Create the pool and a GitHub OIDC provider
Set your context. Use the project number, not the ID, in the provider resource names later; mixing them up is the most common copy-paste failure.
export PROJECT_ID="acme-cicd-prod"
export PROJECT_NUMBER="$(gcloud projects describe "${PROJECT_ID}" --format='value(projectNumber)')"
export POOL_ID="github-pool"
export PROVIDER_ID="github-provider"
export GITHUB_ORG="acme-corp"
export GITHUB_REPO="acme-corp/payments-service"
gcloud config set project "${PROJECT_ID}"
gcloud services enable \
iam.googleapis.com \
iamcredentials.googleapis.com \
sts.googleapis.com
Create the pool:
gcloud iam workload-identity-pools create "${POOL_ID}" \
--project="${PROJECT_ID}" \
--location="global" \
--display-name="GitHub Actions pool"
Create the OIDC provider for GitHub. The --issuer-uri is GitHub’s fixed OIDC issuer. The attribute mapping translates GitHub’s JWT claims into Google attributes, and the attribute condition restricts trust to a single org before any IAM binding is even consulted.
gcloud iam workload-identity-pools providers create-oidc "${PROVIDER_ID}" \
--project="${PROJECT_ID}" \
--location="global" \
--workload-identity-pool="${POOL_ID}" \
--display-name="GitHub provider" \
--issuer-uri="https://token.actions.githubusercontent.com" \
--attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.ref=assertion.ref,attribute.repository_owner=assertion.repository_owner" \
--attribute-condition="assertion.repository_owner == '${GITHUB_ORG}'"
The attribute condition is not cosmetic. Without it, any GitHub repository on the planet whose token names your provider as audience would pass provider validation; only the downstream IAM binding would stop them. Pinning
repository_owner(orrepository) at the provider closes that gap at the front door. This is the single most important hardening step in the whole setup.
4. Attribute mappings and conditions to scope trust
google.subject is special: it becomes the federated principal’s identity and is what shows up in audit logs. Mapping it to assertion.sub gives you a subject like repo:acme-corp/payments-service:ref:refs/heads/main. The custom attribute.* values are what you reference in IAM bindings to scope access more precisely.
A few claims worth mapping deliberately:
| GitHub claim | Example value | Use it to scope by |
|---|---|---|
repository |
acme-corp/payments-service |
A specific repo |
ref |
refs/heads/main |
A branch |
repository_owner |
acme-corp |
The whole org |
environment |
production |
A GitHub deployment environment |
The environment claim is the strongest control GitHub offers. It is only present when the job targets a protected GitHub Environment, which can require manual approval and restrict which branches may deploy. Tightening the attribute condition to demand it means a token is only honored for an approved production deploy:
# Tighten an existing provider to require a protected environment.
gcloud iam workload-identity-pools providers update-oidc "${PROVIDER_ID}" \
--project="${PROJECT_ID}" \
--location="global" \
--workload-identity-pool="${POOL_ID}" \
--attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.environment=assertion.environment,attribute.repository_owner=assertion.repository_owner" \
--attribute-condition="assertion.repository_owner == '${GITHUB_ORG}' && assertion.environment == 'production'"
Defense in depth: enforce coarse trust (
repository_owner) at the attribute condition, then enforce fine-grained trust (exact repo, branch, or environment) at the IAM binding in the next step. The condition is evaluated for every token; the binding decides which SA a passing token may use.
5. Bind the external identity to a service account with least privilege
Create (or reuse) a service account whose roles are exactly what the pipeline needs. Resist the urge to grant roles/editor; scope to the specific deploy roles.
gcloud iam service-accounts create gh-deployer \
--project="${PROJECT_ID}" \
--display-name="GitHub Actions deployer"
export DEPLOY_SA="gh-deployer@${PROJECT_ID}.iam.gserviceaccount.com"
# Example: this pipeline only deploys Cloud Run and reads from Artifact Registry.
gcloud projects add-iam-policy-binding "${PROJECT_ID}" \
--member="serviceAccount:${DEPLOY_SA}" \
--role="roles/run.admin"
gcloud projects add-iam-policy-binding "${PROJECT_ID}" \
--member="serviceAccount:${DEPLOY_SA}" \
--role="roles/artifactregistry.writer"
Now grant the federated identity permission to impersonate that SA. The member uses the principalSet:// prefix and references your mapped attribute, scoping impersonation to one repository:
gcloud iam service-accounts add-iam-policy-binding "${DEPLOY_SA}" \
--project="${PROJECT_ID}" \
--role="roles/iam.workloadIdentityUser" \
--member="principalSet://iam.googleapis.com/projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/${POOL_ID}/attribute.repository/${GITHUB_REPO}"
The principal identifier follows a strict grammar:
- A single subject:
principal://.../subject/{value} - All identities matching a mapped attribute:
principalSet://.../attribute.{name}/{value} - The entire pool:
principalSet://.../workloadIdentityPools/{pool}/*(avoid in production)
To pin to a branch, bind on attribute.ref with value refs/heads/main instead of attribute.repository. To pin to a GitHub Environment, bind on attribute.environment.
Never bind
roles/iam.workloadIdentityUserto the whole-pool/*principal set in production. That trusts every token the provider accepts. Bind to the narrowest attribute you can — ideally a single repo plus branch.
6. Configure the GitHub Actions workflow and verify the exchange
The workflow needs id-token: write permission so GitHub will mint the OIDC token, plus contents: read. Reference the provider by its full resource name and name the SA to impersonate.
name: deploy
on:
push:
branches: [main]
permissions:
contents: read
id-token: write # required for GitHub to issue the OIDC token
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # ties the job to the protected GitHub Environment
steps:
- uses: actions/checkout@v4
- id: auth
uses: google-github-actions/auth@v2
with:
project_id: acme-cicd-prod
workload_identity_provider: projects/123456789012/locations/global/workloadIdentityPools/github-pool/providers/github-provider
service_account: gh-deployer@acme-cicd-prod.iam.gserviceaccount.com
- uses: google-github-actions/setup-gcloud@v2
- name: Prove identity
run: gcloud auth list
- name: Deploy
run: |
gcloud run deploy payments-service \
--image="us-docker.pkg.dev/acme-cicd-prod/apps/payments:${GITHUB_SHA}" \
--region=us-central1
The auth action handles the STS exchange and writes a credential file, exporting GOOGLE_APPLICATION_CREDENTIALS so every downstream gcloud, gsutil, and Terraform google provider call picks it up automatically. There is no key anywhere in this workflow.
The
workload_identity_providervalue uses the project number. If you paste the project ID there, the exchange fails with an opaque permission error. This is the most frequent real-world misconfiguration.
Verify
First, confirm the provider and binding from your workstation:
# Provider exists with the issuer and condition you expect.
gcloud iam workload-identity-pools providers describe "${PROVIDER_ID}" \
--project="${PROJECT_ID}" \
--location="global" \
--workload-identity-pool="${POOL_ID}"
# The SA's IAM policy shows the principalSet member on workloadIdentityUser.
gcloud iam service-accounts get-iam-policy "${DEPLOY_SA}" \
--project="${PROJECT_ID}"
Then run the workflow and read the audit trail. A successful exchange emits an STS event; the federated principal appears as the authentication info. Look for the token-generation calls:
gcloud logging read \
'protoPayload.serviceName="sts.googleapis.com" OR protoPayload.serviceName="iamcredentials.googleapis.com"' \
--project="${PROJECT_ID}" \
--limit=10 \
--format="table(timestamp, protoPayload.methodName, protoPayload.authenticationInfo.principalSubject)"
The principalSubject field is your proof of who exchanged the token — expect something like principal://.../subject/repo:acme-corp/payments-service:ref:refs/heads/main. If the job fails at the auth step, the message almost always points to one of three causes: the project number/ID swap, an attribute condition the token did not satisfy, or a missing roles/iam.workloadIdentityUser binding for the exact principal.
7. Extending the pattern to GitLab, Terraform Cloud, and AWS
The pool and impersonation binding stay identical; only the provider’s issuer, audience, and claim names change.
GitLab CI issues an OIDC JWT in a CI/CD variable (commonly GITLAB_OIDC_TOKEN via the id_tokens keyword). Its issuer is your GitLab instance URL, and useful claims include project_path and ref.
gcloud iam workload-identity-pools providers create-oidc "gitlab-provider" \
--project="${PROJECT_ID}" \
--location="global" \
--workload-identity-pool="${POOL_ID}" \
--issuer-uri="https://gitlab.com" \
--attribute-mapping="google.subject=assertion.sub,attribute.project_path=assertion.project_path,attribute.ref=assertion.ref" \
--attribute-condition="assertion.project_path == 'acme-group/payments'"
Terraform Cloud / HCP Terraform presents an OIDC token whose issuer is https://app.terraform.io. Map terraform_workspace_id or the sub claim and scope the binding to a workspace. The audience defaults to the provider resource name, configurable via TFC_WORKLOAD_IDENTITY_AUDIENCE.
gcloud iam workload-identity-pools providers create-oidc "tfc-provider" \
--project="${PROJECT_ID}" \
--location="global" \
--workload-identity-pool="${POOL_ID}" \
--issuer-uri="https://app.terraform.io" \
--attribute-mapping="google.subject=assertion.sub,attribute.terraform_workspace_id=assertion.terraform_workspace_id" \
--attribute-condition="assertion.terraform_organization_name == 'acme'"
AWS workloads use a different provider type entirely — create-aws rather than create-oidc — because the trust is built on AWS account identity rather than a generic OIDC issuer. An EC2 instance or EKS pod with an IAM role can then federate to GCP without any AWS access keys crossing the boundary.
gcloud iam workload-identity-pools providers create-aws "aws-provider" \
--project="${PROJECT_ID}" \
--location="global" \
--workload-identity-pool="${POOL_ID}" \
--account-id="111122223333" \
--attribute-condition="assertion.arn.startsWith('arn:aws:sts::111122223333:assumed-role/ci-runner')"
Codify whichever providers you use rather than running CLI commands by hand. The Terraform shape mirrors the gcloud flags:
resource "google_iam_workload_identity_pool" "github" {
workload_identity_pool_id = "github-pool"
display_name = "GitHub Actions pool"
}
resource "google_iam_workload_identity_pool_provider" "github" {
workload_identity_pool_id = google_iam_workload_identity_pool.github.workload_identity_pool_id
workload_identity_pool_provider_id = "github-provider"
attribute_mapping = {
"google.subject" = "assertion.sub"
"attribute.repository" = "assertion.repository"
"attribute.repository_owner" = "assertion.repository_owner"
}
attribute_condition = "assertion.repository_owner == 'acme-corp'"
oidc {
issuer_uri = "https://token.actions.githubusercontent.com"
}
}
resource "google_service_account_iam_member" "wif_user" {
service_account_id = google_service_account.gh_deployer.name
role = "roles/iam.workloadIdentityUser"
member = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.github.name}/attribute.repository/acme-corp/payments-service"
}
8. Auditing exchanges and locking down with policy
A federated setup is only as good as the controls around it. Three layers, from broad to narrow:
- Org policy. Keep
iam.disableServiceAccountKeyCreationenforced org-wide so no one quietly falls back to JSON keys. Consideriam.workloadIdentityPoolProvidersto allowlist exactly which external issuer URIs may be configured anywhere in the org — it stops a team from wiring up a provider that trusts an attacker-controlled issuer. - Attribute conditions and bindings. Audit every provider for a non-empty attribute condition and every
workloadIdentityUserbinding for a narrowprincipalSet. A binding to/*or a provider with no condition is a finding. - Logs. STS and IAM Credentials calls land in Cloud Audit Logs. Build a sink or alert on token generation from unexpected
principalSubjectvalues, and watch for exchanges outside business hours or from repos that should not deploy.
# Inventory every WIF provider in the project and check for missing conditions.
gcloud iam workload-identity-pools providers list \
--project="${PROJECT_ID}" \
--location="global" \
--workload-identity-pool="${POOL_ID}" \
--format="table(name, attributeCondition, disabled)"
To revoke trust instantly during an incident, disable the provider (existing federated tokens stop being honored) without tearing down the pool or the binding:
gcloud iam workload-identity-pools providers update-oidc "${PROVIDER_ID}" \
--project="${PROJECT_ID}" \
--location="global" \
--workload-identity-pool="${POOL_ID}" \
--disabled
Enterprise scenario
A platform team running a shared acme-cicd-prod project bound roles/iam.workloadIdentityUser to attribute.repository_owner/acme-corp so any repo in the org could deploy with one provider. Convenient — until a security review flagged that a developer could spin up a brand-new repo under the org, point a workflow at the provider, and impersonate the deploy SA that held roles/run.admin. The org-wide attribute condition (repository_owner == 'acme-corp') was doing its job; the binding was the hole.
The real gotcha surfaced when they tried to tighten it: GitHub’s sub claim format differs between a normal branch push (repo:acme-corp/svc:ref:refs/heads/main) and an environment-gated job (repo:acme-corp/svc:environment:production). Their first fix bound on google.subject directly and silently broke every non-environment job.
The fix was to stop binding on sub and bind on an explicit mapped attribute per service, gated behind a protected GitHub Environment, so a new repo gets nothing until IAM is changed via Terraform:
gcloud iam service-accounts add-iam-policy-binding "${DEPLOY_SA}" \
--project="${PROJECT_ID}" \
--role="roles/iam.workloadIdentityUser" \
--member="principalSet://iam.googleapis.com/projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/${POOL_ID}/attribute.repository/acme-corp/payments-service"
They then split the one shared SA into per-service deployers, each bound to exactly one attribute.repository. The principle that stuck: enforce coarse trust at the provider condition, but never let the binding be broader than a single repo plus environment.
Checklist
Pitfalls and next steps
The failures that cost the most time: using the project ID where the provider name needs the project number; forgetting permissions: id-token: write (the job silently has no token to exchange); creating a provider with no attribute condition and assuming the IAM binding alone is enough; and binding workloadIdentityUser to the whole pool, which trusts every repo the issuer can speak for. Also remember that mapped attributes are fixed at provider-creation governance time — adding a new attribute.* mapping later does not retroactively let you bind on it for tokens you have already reasoned about, so plan your claim mappings up front.
Next, push every pool and provider into Terraform behind a plan gate, evaluate direct resource access (granting roles to the principalSet and dropping the intermediate SA) for services and clients that support it, and wire a Security Command Center or log-based alert that fires on any token exchange from an unexpected subject. At that point the last long-lived key in your estate can be deleted for good.