TL;DR

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
export PROJECT_ID=my-project
export PROJECT_NUMBER=$(gcloud projects describe $PROJECT_ID --format='value(projectNumber)')
export GITHUB_ORG=my-org
export REPO=my-org/my-repo

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

gcloud iam workload-identity-pools providers create-oidc github-oidc \
  --project=$PROJECT_ID --location=global --workload-identity-pool=github \
  --display-name="GitHub OIDC" \
  --issuer-uri="https://token.actions.githubusercontent.com" \
  --attribute-mapping="google.subject=assertion.sub,attribute.actor=assertion.actor,attribute.repository=assertion.repository,attribute.repository_owner=assertion.repository_owner" \
  --attribute-condition="assertion.repository_owner == '$GITHUB_ORG'"

gcloud iam service-accounts create github-actions --project=$PROJECT_ID --display-name="GitHub Actions"
gcloud iam service-accounts add-iam-policy-binding github-actions@$PROJECT_ID.iam.gserviceaccount.com \
  --project=$PROJECT_ID --role=roles/iam.workloadIdentityUser \
  --member="principalSet://iam.googleapis.com/projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/github/attribute.repository/$REPO"

And in the workflow:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write            # without this GitHub does not issue the OIDC token
    steps:
      - uses: actions/checkout@v4
      - id: auth
        uses: google-github-actions/auth@v3
        with:
          workload_identity_provider: projects/123456789012/locations/global/workloadIdentityPools/github/providers/github-oidc
          service_account: github-actions@my-project.iam.gserviceaccount.com
      - uses: google-github-actions/setup-gcloud@v3
      - run: gcloud storage ls

Whatever roles the pipeline needs (Artifact Registry, Cloud Run, GKE…) go to github-actions@… like to any service account.

Why

The classic alternative is exporting a service account JSON key and storing it as a GitHub secret. That key never expires, anyone with access to the repo or the logs can copy it, and rotating it is manual work nobody does. With Workload Identity Federation GitHub issues a short-lived OIDC token for each job, Google verifies it against GitHub’s issuer, checks your attribute condition, and hands out temporary service account credentials. There is no secret to store or rotate, and you can restrict who gets in down to the repo or branch.

The pieces

PieceWhat it is
Pool (github)Container for external identities; one per provider is enough
OIDC provider (github-oidc)Trusts https://token.actions.githubusercontent.com and maps token claims (repository, repository_owner, actor, ref) to attributes
Attribute conditionCEL filter over those claims. Without it, any GitHub repo could try to enter the pool; the official README says to always set one
workloadIdentityUser bindingWhich pool identities may impersonate the service account: attribute.repository/my-org/my-repo, or attribute.repository_owner/my-org for the whole organization
auth@v3Exchanges GitHub’s token for Google credentials and leaves them configured for gcloud, the SDKs, and Terraform

To restrict to a branch, map the claim and use it in both the condition and the binding:

1
2
--attribute-mapping="…,attribute.ref=assertion.ref"
--attribute-condition="assertion.repository_owner == 'my-org' && assertion.ref == 'refs/heads/main'"

With Terraform

The official module creates pool, provider, and bindings in one block:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
module "github_actions_sa" {
  source     = "terraform-google-modules/service-accounts/google"
  version    = "~> 4.0"
  project_id = var.project_id
  names      = ["github-actions"]
  project_roles = ["${var.project_id}=>roles/artifactregistry.writer"]
}

module "gh_oidc" {
  source              = "terraform-google-modules/github-actions-runners/google//modules/gh-oidc"
  project_id          = var.project_id
  pool_id             = "github"
  provider_id         = "github-oidc"
  attribute_condition = "assertion.repository_owner == 'my-org'"
  sa_mapping = {
    "github-actions" = {
      sa_name   = "projects/${var.project_id}/serviceAccounts/${module.github_actions_sa.email}"
      attribute = "attribute.repository/my-org/my-repo"
    }
  }
}

output "workload_identity_provider" { value = module.gh_oidc.provider_name }

The output is exactly the value that goes into the workflow’s workload_identity_provider.

Without a service account

auth@v3 also supports direct federation: the principalSet gets IAM roles without going through a service account (workload_identity_provider and project_id, no service_account). Fewer pieces, but not every service accepts federated principals; Artifact Registry, GCS, and most APIs do, and it is the path Google recommends for new setups.

Traps

  • id-token: write in the job’s permissions. Without it, auth fails with “Unable to get ACTIONS_ID_TOKEN_REQUEST_URL”.
  • Project number, not ID, in the provider path and the principalSet: projects/123456789012/….
  • Permission 'iam.serviceAccounts.getAccessToken' denied: the roles/iam.workloadIdentityUser binding is missing or attribute.repository does not match (case and exact repo name).
  • The condition must reference mapped claims: if you use assertion.ref in the condition without mapping it to an attribute, the provider gets created but never matches.
  • Forks and external PRs: tokens from a fork’s PR carry the fork’s repository; a condition on repository_owner keeps them out, which is usually what you want.
  • Rotation: nothing to rotate, but something to review. gcloud iam workload-identity-pools providers describe and the STS audit logs tell you who is coming in.

Reference: google-github-actions/auth and Workload Identity Federation in the Google Cloud documentation.