GCP Identity

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.

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.

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.

GCPWorkload Identity FederationOIDCCI/CDIAM

Comments

Keep Reading