GCP Lesson 41 of 98

Keyless Authentication to GCP: Workload Identity Federation for GitHub Actions and CI/CD

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.

In a nutshell

Your CI system — GitHub Actions, GitLab CI, a Jenkins box — needs to deploy things into GCP: push an image, roll out a Cloud Run revision, run terraform apply. To do that it has to authenticate as someone GCP recognizes. The old way was to create a service account key (a long JSON secret), paste it into the CI system, and let the pipeline log in with it. That key never expires, works for anyone who copies it, and has no idea whether it is being used by your pipeline or by an attacker who found it in a build log. Workload Identity Federation is the keyless replacement. Your CI already knows how to prove who it is to its own identity provider; WIF teaches GCP to trust that proof directly, so there is no key to store, leak, or rotate.

Here is the mental model. A downloaded SA key is like taping a spare house key under the doormat and mailing copies to every contractor who has ever worked on your house — it opens the door forever, for anyone who finds one, and you have no record of who is holding them. WIF is like a building with a front desk: a contractor shows a photo ID they already carry, the desk checks it against a trusted list, and issues a visitor badge that only opens one floor and expires at 5 p.m. Nobody walks around with a permanent master key, and revoking access is as simple as taking that name off the trusted list.

Three actors do all the work. The CI job is the visitor. The OIDC token — a short-lived, signed JSON Web Token minted by the CI’s own provider — is the photo ID. GCP’s Security Token Service plus a workload identity pool provider is the front desk that checks the ID against the trusted list you configured and hands back a badge (a federated credential) good for about an hour. Get those three straight and everything below is detail.

Level: Advanced · Time: ~30 min

Workload Identity Federation keyless auth flow: a CI workload proves identity with a signed OIDC token, GCP STS validates it against the pool provider's attribute condition and mapping, returns a federated principal, and that principal impersonates a least-privilege service account to deploy — no key file anywhere

Read the diagram left to right: the CI job mints a short-lived OIDC token (no key file exists — badge 2 is the whole point), POSTs it to GCP’s Security Token Service, which validates it against the pool provider’s attribute condition (badge 3, the front door) and translates its claims through the attribute mapping (badge 4). What comes back is a federated principalSet that is bound with roles/iam.workloadIdentityUser (badge 5) to impersonate a least-privilege deploy service account (badge 6) — the credential your gcloud and Terraform steps actually use.

Prerequisites and what you’ll be able to do

You will get the most from this lesson if you are already comfortable with a few earlier building blocks:

You do not need to know OIDC internals in advance — this lesson builds that up. After working through it you will be able to:

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:

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.disableServiceAccountKeyCreation org 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:

  1. GitHub mints a signed OIDC JWT for the running job. Its iss is https://token.actions.githubusercontent.com, and its claims include sub, repository, ref, actor, and workflow.
  2. The google-github-actions/auth step POSTs that JWT to GCP STS (sts.googleapis.com), naming the pool provider as the audience.
  3. STS validates the JWT signature against GitHub’s published JWKS, evaluates your attribute condition, and applies your attribute mapping.
  4. If everything passes, STS returns a short-lived federated access token representing the external identity (a principalSet).
  5. 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 (or repository) 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:

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.workloadIdentityUser to 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_provider value 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:

# 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.

Going deeper

The seven numbered sections give you a working, hardened setup. This section is for when you need to debug the exchange from first principles, decide between impersonation and direct access, or defend the design in a security review.

The raw STS token exchange

The google-github-actions/auth action is a convenience wrapper around a plain OAuth 2.0 token-exchange call (RFC 8693). Knowing the raw shape is what lets you reproduce a failure with curl and see the real error. The request STS receives looks like this (form-encoded):

POST https://sts.googleapis.com/v1/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:token-exchange
audience=//iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID
requested_token_type=urn:ietf:params:oauth:token-type:access_token
scope=https://www.googleapis.com/auth/cloud-platform
subject_token_type=urn:ietf:params:oauth:token-type:jwt
subject_token=<the GitHub OIDC JWT>

The audience is the provider’s full resource name with the leading //iam.googleapis.com/ — and it uses the project number. STS validates the subject_token signature against the issuer’s JWKS (which Google fetches and caches from <issuer-uri>/.well-known/openid-configuration), checks the token’s own aud claim matches the configured audience, evaluates your attribute condition, applies the mapping, and returns a federated access token. If you want the impersonated SA token instead, that federated token is then presented to iamcredentials.googleapis.com’s generateAccessToken — which is the second event you saw in the audit query.

Application Default Credentials without an Action

Outside GitHub Actions — a self-hosted runner, a Jenkins agent, any process that can read a file — you generate a credential configuration file that Application Default Credentials (ADC) understands. It contains no secret; it only tells the client library where to read the fresh OIDC token and which provider/SA to target:

gcloud iam workload-identity-pools create-cred-config \
  "projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/${POOL_ID}/providers/${PROVIDER_ID}" \
  --service-account="${DEPLOY_SA}" \
  --credential-source-file="/var/run/oidc/token" \
  --output-file="/etc/gcp/creds.json"

The resulting file has "type": "external_account" (contrast with a key file’s "type": "service_account") and looks like:

{
  "type": "external_account",
  "audience": "//iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID",
  "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
  "token_url": "https://sts.googleapis.com/v1/token",
  "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/DEPLOY_SA@PROJECT_ID.iam.gserviceaccount.com:generateAccessToken",
  "credential_source": {
    "file": "/var/run/oidc/token",
    "format": { "type": "text" }
  }
}

Point GOOGLE_APPLICATION_CREDENTIALS at that file and every Google client library federates transparently. The credential_source can also be a url (for metadata-style token endpoints) or an environment variable — the same file shape covers AWS and Azure sources too.

Direct resource access — dropping the service account

Impersonation is the compatible default, but you can grant IAM roles to the principalSet directly and delete the intermediate SA entirely. The federated principal itself becomes the member:

gcloud storage buckets add-iam-policy-binding gs://acme-artifacts \
  --role="roles/storage.objectViewer" \
  --member="principalSet://iam.googleapis.com/projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/${POOL_ID}/attribute.repository/acme-corp/payments-service"

Trade-offs to weigh before you commit:

Service account impersonation Direct resource access
Client/library support Universal Most, but not every API/library honors federated principals
Extra hop generateAccessToken call to IAM Credentials None — STS token used directly
Audit subject The SA email + the principalSubject The principalSet only
Blast radius knob The SA’s roles Roles granted per resource to the principalSet
Good fit CI that runs gcloud/Terraform broadly A workload touching a small, known set of resources

Limits and edges that bite in production

Security posture and cost

WIF itself has no line-item cost — STS and IAM Credentials calls are free; you pay only for the audit logs you choose to retain. The security wins are the point: no long-lived secret to exfiltrate, tokens that expire in ~1 hour, and an audit trail (principalSubject) that names the exact repo/branch behind every action. Layer it with an org-policy guardrail that blocks key creation and allowlists issuer URIs, and pair the least-privilege SA design with the impersonation and IAM-condition patterns in Deny Policies, Conditions, and Impersonation Chains. For a workload behind VPC Service Controls, remember STS and IAM Credentials are the API surfaces the exchange touches — include them when you scope a perimeter.

Practice challenges

Work these in order; each <details> block has a runnable answer and a one-line reason. Use placeholders (PROJECT_ID, PROJECT_NUMBER, org/repo names) — do not paste real IDs.

1. (Beginner) Turn on the APIs and create an empty pool. WIF needs three APIs and a pool container before anything else. Enable them and create a pool named github-pool.

<details><summary>Solution</summary>

gcloud services enable iam.googleapis.com iamcredentials.googleapis.com sts.googleapis.com \
  --project="${PROJECT_ID}"

gcloud iam workload-identity-pools create "github-pool" \
  --project="${PROJECT_ID}" --location="global" \
  --display-name="GitHub Actions pool"

Why: sts.googleapis.com performs the exchange and iamcredentials.googleapis.com mints the impersonated token — without both enabled the auth step fails opaquely. </details>

2. (Beginner) Create a GitHub OIDC provider that trusts only your org. Map repository and repository_owner, and pin the condition to repository_owner == 'acme-corp'.

<details><summary>Solution</summary>

gcloud iam workload-identity-pools providers create-oidc "github-provider" \
  --project="${PROJECT_ID}" --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 == 'acme-corp'"

Why: the condition rejects every token from outside your org before any IAM binding is consulted — the single most important hardening step. </details>

3. (Intermediate) Bind one repo to a least-privilege SA. Let only acme-corp/payments-service impersonate gh-deployer, and give that SA just roles/run.admin.

<details><summary>Solution</summary>

gcloud projects add-iam-policy-binding "${PROJECT_ID}" \
  --member="serviceAccount:gh-deployer@${PROJECT_ID}.iam.gserviceaccount.com" \
  --role="roles/run.admin"

gcloud iam service-accounts add-iam-policy-binding \
  "gh-deployer@${PROJECT_ID}.iam.gserviceaccount.com" \
  --role="roles/iam.workloadIdentityUser" \
  --member="principalSet://iam.googleapis.com/projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/github-pool/attribute.repository/acme-corp/payments-service"

Why: scoping the principalSet to one attribute.repository means only that repo’s tokens can impersonate the SA — never bind the whole-pool /*. </details>

4. (Intermediate) Write the minimal workflow that proves the exchange. The job should authenticate keylessly and print the active identity — nothing more.

<details><summary>Solution</summary>

permissions:
  contents: read
  id-token: write
jobs:
  whoami:
    runs-on: ubuntu-latest
    steps:
      - uses: google-github-actions/auth@v2
        with:
          project_id: acme-cicd-prod
          workload_identity_provider: projects/PROJECT_NUMBER/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
      - run: gcloud auth list

Why: id-token: write is what makes GitHub mint the OIDC token; without it the job has nothing to exchange. Note the provider path uses the project number. </details>

5. (Advanced) Gate impersonation behind a protected production environment. A token should only be honored when the job runs in the GitHub production environment. Update the mapping, the condition, and the binding.

<details><summary>Solution</summary>

gcloud iam workload-identity-pools providers update-oidc "github-provider" \
  --project="${PROJECT_ID}" --location="global" \
  --workload-identity-pool="github-pool" \
  --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 == 'acme-corp' && assertion.environment == 'production'"

gcloud iam service-accounts add-iam-policy-binding \
  "gh-deployer@${PROJECT_ID}.iam.gserviceaccount.com" \
  --role="roles/iam.workloadIdentityUser" \
  --member="principalSet://iam.googleapis.com/projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/github-pool/attribute.environment/production"

Why: the environment claim only appears when the job targets a protected Environment (which can require approval), so a non-prod job’s token fails the condition outright — and the workflow must set environment: production. </details>

6. (Advanced) Audit the whole project for weak WIF config. Produce a one-liner that lists every provider with its attribute condition so you can spot any that are missing one, then show the direct-access binding you would use to drop the SA for a read-only bucket workload.

<details><summary>Solution</summary>

# Any row with an empty attributeCondition is a finding.
gcloud iam workload-identity-pools providers list \
  --project="${PROJECT_ID}" --location="global" \
  --workload-identity-pool="github-pool" \
  --format="table(name, attributeCondition, disabled)"

# Direct resource access — no SA, principalSet is the member itself.
gcloud storage buckets add-iam-policy-binding gs://acme-artifacts \
  --role="roles/storage.objectViewer" \
  --member="principalSet://iam.googleapis.com/projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/github-pool/attribute.repository/acme-corp/payments-service"

Why: a provider with no condition trusts every token the issuer will sign; and for a small, fixed resource set, direct access removes the impersonation hop (accepting the compatibility caveat). </details>

Common beginner mistakes

Checklist

Glossary

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.

GCPWorkload Identity FederationOIDCCI/CDIAM
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments