A GitHub Actions workflow that deploys to GCP needs some way to authenticate. For years the answer was a JSON service account key stored as a GitHub repository secret. Everyone knew it was the wrong answer, everyone used it anyway, and the industry produced an endless supply of blog posts about accidentally leaked keys. Workload Identity Federation is the fix. Instead of a static key, GitHub’s runner exchanges its built-in OIDC token for a short-lived GCP access token through a trust relationship you configure once. The key file goes away. The rotation problem goes away. The “we left a demo secret in a public repo” incident goes away. This guide walks through the exact setup on a real project, the IAM policy binding that actually scopes access to one repository, the GitHub workflow YAML that authenticates and then pushes to Artifact Registry, and the four errors you will hit on the first attempt.
This is the external Workload Identity Federation product, not to be confused with Workload Identity Federation for GKE. They share the underlying Security Token Service but they are configured differently: GKE’s pool is managed automatically for Kubernetes ServiceAccounts, while the external WIF product is what you use for GitHub Actions, AWS EC2 instances, Azure VMs, on-prem OIDC workloads, and anything else that lives outside a GKE cluster. This guide is the external WIF version specifically for GitHub Actions.
Tested April 2026 on GCP with google-cloud-cli 521, GitHub Actions runner ubuntu-24.04, and the google-github-actions/auth v3 action
Why Workload Identity Federation Matters
The old way was a JSON service account key, base64-encoded, stashed in a GitHub secret, pulled into the runner, written to a file, and passed to gcloud auth activate-service-account. Every step in that chain is a place where the key can leak. The workflow author can accidentally print it in a log. A malicious action can read the file. A forked PR workflow can exfiltrate it. The key itself lives forever unless somebody rotates it manually, and nobody rotates it manually.
Workload Identity Federation flips the model. GitHub already issues a signed OIDC token to every running job ($ACTIONS_ID_TOKEN_REQUEST_URL and $ACTIONS_ID_TOKEN_REQUEST_TOKEN). That token is short-lived, tied to the specific job run, and contains claims about which repository, branch, environment, and actor triggered it. WIF configures GCP’s Security Token Service to accept that token as proof of identity, exchange it for a federated Google token, and optionally chain-impersonate a regular service account. The resulting access token expires after an hour. No key file ever touches the runner. If an attacker somehow leaks the access token, the blast radius is one hour on the specific resources that one service account was granted.
Google officially deprecated service account key files for CI/CD in 2023 and the organization policy that blocks new key creation is the default in new organizations. If you are still using JSON keys for GitHub Actions in 2026, you are on borrowed time. Migrate before the next org-policy refresh forces the issue.
The Mental Model: Four Pieces That Click Together
WIF has four configuration objects and understanding how they chain together is the hard part. Once the model clicks, the commands are mechanical.
- Workload Identity Pool. A container for external identities inside a GCP project. You create one pool per external identity provider family (one for GitHub, one for GitLab, one for AWS, and so on). The pool is where IAM bindings are anchored.
- Workload Identity Pool Provider. An OIDC (or SAML or AWS) configuration attached to the pool. For GitHub Actions this is an OIDC provider pointing at
https://token.actions.githubusercontent.com. The provider defines which claims from the incoming token get mapped to GCP attributes (repo name, actor, branch, etc.) and an optional attribute condition that filters which incoming tokens are even considered. - Principal. A reference to an external identity that GCP IAM can grant roles to. For GitHub Actions the principal is usually a
principalSet://URL that matches every token from a specific repository, or aprincipal://URL that matches one specific token (subject claim). - Google Service Account (optional chain-impersonation). Two patterns are supported. Direct resource access binds the GitHub principal directly to an IAM role on a GCP resource. Chain-impersonation binds the GitHub principal to
roles/iam.workloadIdentityUseron a normal Google Service Account, and then the workflow impersonates that GSA to get an access token. Both work. Direct is newer and simpler, impersonation is the default in most tutorials because it works with every API.
The chain reads: GitHub job produces OIDC token → provider validates the token and maps claims to attributes → IAM policy on the target (either the service account or the resource directly) matches the principal → GCP issues a short-lived access token.
Prerequisites
- A GCP project with billing enabled
gcloudCLI 520+ authenticated as an owner or security adminiam.googleapis.com,iamcredentials.googleapis.com, and whatever APIs your workflow will call enabled. For this guide’s example, alsoartifactregistry.googleapis.com- A GitHub repository you control where you can add a workflow file
Enable the APIs:
gcloud services enable \
iam.googleapis.com \
iamcredentials.googleapis.com \
artifactregistry.googleapis.com \
--project=PROJECT_ID
Step 1: Create the Workload Identity Pool
Capture the project ID and project number first. Both are needed at different points in the setup and mixing them up is the single most common mistake:
PROJECT_ID=$(gcloud config get-value project)
PROJECT_NUMBER=$(gcloud projects describe $PROJECT_ID --format="value(projectNumber)")
echo "ID: $PROJECT_ID"
echo "NUMBER: $PROJECT_NUMBER"
Create a pool. One pool per external identity family is the recommended shape, so name it something like github-pool or github-pool-prod:
gcloud iam workload-identity-pools create github-pool \
--location=global \
--display-name="GitHub Actions pool"
Output confirms the pool was created:
Created workload identity pool [github-pool].
Step 2: Create the GitHub OIDC Provider
The provider is where you define how GCP validates the incoming GitHub token. Three things matter here. The issuer-uri is the fixed GitHub Actions OIDC endpoint. The attribute-mapping translates claims from the GitHub token into GCP attributes you can reference in IAM bindings. The attribute-condition is a CEL expression that filters which tokens are even considered, and this is the single most important security control: it is how you prevent any GitHub repository on the internet from assuming your identity.
gcloud iam workload-identity-pools providers create-oidc github-provider \
--location=global \
--workload-identity-pool=github-pool \
--display-name="GitHub OIDC provider" \
--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 == 'your-org-name'"
Replace your-org-name with your actual GitHub organization or username. The attribute condition is non-optional. Without it, any GitHub repository on the internet could potentially trigger the exchange, and the Google Cloud docs now refuse to create a provider that has no condition at all. Output confirms creation:
Created workload identity pool provider [github-provider].
Capture the full provider resource name. This is what the GitHub workflow YAML will reference. The format is projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL/providers/PROVIDER and that exact string goes into the workflow:
gcloud iam workload-identity-pools providers describe github-provider \
--location=global \
--workload-identity-pool=github-pool \
--format="value(name)"
projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/github-pool/providers/github-provider
Step 3: Create a Service Account for the Workflow
Chain impersonation is the pattern most tutorials show. You create a normal Google Service Account, grant it the IAM roles the workflow actually needs (Artifact Registry writer, Cloud Run deployer, whatever), and then allow the GitHub principal to impersonate it. The workflow does not hold any credentials for this SA; it fetches a short-lived access token through the impersonation chain.
gcloud iam service-accounts create github-deploy \
--display-name="GitHub Actions deployer"
Grant the SA the roles the workflow actually needs. For this guide’s Artifact Registry push example, that is artifactregistry.writer:
gcloud projects add-iam-policy-binding $PROJECT_ID \
--role=roles/artifactregistry.writer \
--member="serviceAccount:github-deploy@$PROJECT_ID.iam.gserviceaccount.com" \
--condition=None
If the workflow will deploy to Cloud Run, grant run.developer and iam.serviceAccountUser on the Cloud Run runtime SA as well. The rule of thumb is to grant only what the workflow actually calls; resist the temptation to grant editor or owner because it is easier.
Step 4: Bind the GitHub Principal to the Service Account
This is the step that ties the GitHub repository to the GCP identity. The principalSet URL matches every OIDC token from a specific repository, because the attribute mapping you configured earlier exposed attribute.repository. The format uses the pool resource name and the attribute name:
gcloud iam service-accounts add-iam-policy-binding \
github-deploy@$PROJECT_ID.iam.gserviceaccount.com \
--role=roles/iam.workloadIdentityUser \
--member="principalSet://iam.googleapis.com/projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/github-pool/attribute.repository/your-org-name/your-repo-name"
Replace your-org-name/your-repo-name with the actual GitHub owner/repo string. This binding means: any token from that repository can impersonate the github-deploy service account, provided it also satisfies the attribute condition on the provider. If you want to restrict impersonation to a specific branch, use attribute.ref instead and match refs/heads/main. If you want to restrict to a GitHub Environment (for production promotion approvals), use attribute.environment.
Output confirms the binding:
- members:
- principalSet://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/github-pool/attribute.repository/your-org-name/your-repo-name
role: roles/iam.workloadIdentityUser
Step 5: The GitHub Actions Workflow
The workflow uses Google’s official google-github-actions/auth action, currently on v3. Two things are non-negotiable: the id-token: write permission on the job, and the workload_identity_provider input set to the full provider resource name captured earlier.
vim .github/workflows/deploy.yml
The workflow authenticates via WIF, installs gcloud, configures Docker to push to Artifact Registry, builds an image, and pushes it. Every step runs with the short-lived federated token, no JSON key file anywhere:
name: Deploy to GCP
on:
push:
branches: [main]
jobs:
push-image:
runs-on: ubuntu-24.04
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- name: Authenticate to Google Cloud
id: auth
uses: google-github-actions/auth@v3
with:
workload_identity_provider: 'projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/github-pool/providers/github-provider'
service_account: 'github-deploy@PROJECT_ID.iam.gserviceaccount.com'
- name: Set up gcloud
uses: google-github-actions/setup-gcloud@v2
- name: Configure Docker for Artifact Registry
run: gcloud auth configure-docker europe-west1-docker.pkg.dev
- name: Build and push image
run: |
IMAGE=europe-west1-docker.pkg.dev/PROJECT_ID/my-repo/my-app:${{ github.sha }}
docker build -t $IMAGE .
docker push $IMAGE
Replace PROJECT_NUMBER and PROJECT_ID with your real values. Commit the workflow, push to the branch, and watch it run. On the first run you will almost certainly hit one of the errors documented below because the setup has several moving parts and any of them can be off by one character.
Direct Resource Access Instead of Service Account Impersonation
Service account impersonation is the default, but the newer and slightly simpler pattern is direct resource access. You bind the GitHub principal directly to an IAM role on a GCP resource, skipping the intermediate service account. Less to configure, one fewer thing to audit. The tradeoff is that direct access only works for resources whose IAM policies support the principalSet member format, which is most modern APIs but not all of them.
The same Artifact Registry push without the intermediate SA:
gcloud projects add-iam-policy-binding $PROJECT_ID \
--role=roles/artifactregistry.writer \
--member="principalSet://iam.googleapis.com/projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/github-pool/attribute.repository/your-org-name/your-repo-name" \
--condition=None
The workflow YAML then omits the service_account input on the auth action and calls the API directly with the federated token. Check the auth action docs for the exact workflow shape. Prefer direct access for any new workflow. Fall back to impersonation only if a specific API rejects the principalSet member format.
Terraform Version
The full setup in Terraform. This is the version to copy into a platform-team-maintained module. Note that google_iam_workload_identity_pool is the correct resource for external WIF scenarios like this one, which is different from the GKE Workload Identity pool that you do NOT declare in Terraform:
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"
display_name = "GitHub OIDC provider"
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 == 'your-org-name'"
oidc {
issuer_uri = "https://token.actions.githubusercontent.com"
}
}
resource "google_service_account" "github_deploy" {
account_id = "github-deploy"
display_name = "GitHub Actions deployer"
}
resource "google_project_iam_member" "artifact_writer" {
project = var.project_id
role = "roles/artifactregistry.writer"
member = "serviceAccount:${google_service_account.github_deploy.email}"
}
resource "google_service_account_iam_member" "github_impersonation" {
service_account_id = google_service_account.github_deploy.name
role = "roles/iam.workloadIdentityUser"
member = "principalSet://iam.googleapis.com/projects/${data.google_project.current.number}/locations/global/workloadIdentityPools/${google_iam_workload_identity_pool.github.workload_identity_pool_id}/attribute.repository/your-org-name/your-repo-name"
}
data "google_project" "current" {}
Apply with a normal terraform init && terraform apply. The pool and provider are global resources so the location is implicitly global in the Terraform provider.
Troubleshooting
Error: “The caller does not have permission”
Full error from the auth action:
google-github-actions/auth failed with: failed to generate Google Cloud federated token for projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/github-pool/providers/github-provider: {"error":"invalid_grant","error_description":"The given OIDC token does not match any allowed audiences."}
Two common causes. First, the attribute condition on the provider does not match the incoming GitHub token. If the condition requires repository_owner == 'acme' and the repo is in a personal account, the token’s owner claim will be the username, not the org, and the match fails. Widen the condition or move the repo. Second, the workflow is running with an OIDC token whose audience does not match what the auth action expects. Upgrade to google-github-actions/auth@v3 which handles the audience automatically, and do not override the audience input unless you really know what you are doing.
Error: “Permission ‘iam.serviceAccounts.getAccessToken’ denied”
Error: google-github-actions/auth failed with: failed to generate Google Cloud access token for github-deploy@PROJECT_ID.iam.gserviceaccount.com: Permission 'iam.serviceAccounts.getAccessToken' denied on resource (or it may not exist).
The GitHub principal does not have roles/iam.workloadIdentityUser on the target service account, or the principalSet member string has a typo. Recheck the add-iam-policy-binding command and make sure the repository path exactly matches the repo the workflow is running in (case-sensitive, no trailing slash). If you recently added the binding, wait two to seven minutes for IAM propagation before retrying. If the binding is there and still failing, double-check that the provider resource name in the workflow uses the project number, not the project ID.
Error: “Unable to acquire impersonation credentials”
UNAUTHENTICATED: Unable to acquire impersonation credentials: Request had invalid authentication credentials
The iamcredentials.googleapis.com API is not enabled on the project, which means generateAccessToken calls fail silently with an auth error. Enable the API explicitly and wait thirty seconds for the activation to propagate:
gcloud services enable iamcredentials.googleapis.com
Error: “The request was missing required parameter ‘audience'”
This appears if you set the audience input on the auth action incorrectly, usually copied from an older tutorial. The google-github-actions/auth@v3 action computes the correct audience automatically from the provider name. Remove any explicit audience input from the workflow and let the action figure it out.
“id-token: write” permission error
The auth action fails immediately with an error about not being able to fetch the ID token. The job in your workflow YAML is missing the permissions block that grants id-token: write. GitHub Actions disables OIDC token minting by default and requires the workflow to opt in explicitly. Add the block to the job (not the workflow top level, although top-level works too):
jobs:
push-image:
permissions:
contents: read
id-token: write
Scoping Beyond Repository
Binding to attribute.repository gives every workflow in the repo access. For production setups you usually want tighter scoping. Four common refinements.
- Branch-based. Use
attribute.refand matchrefs/heads/main. Only workflows running onmaincan impersonate the SA. Matches most “deploy on merge to main” patterns. - Environment-based. Use
attribute.environmentand match a GitHub Environment name likeproduction. GitHub Environments also support required reviewers, which lets you gate sensitive deploys behind human approval. - Workflow-based. Use
attribute.workflowto restrict to a specific workflow file. Useful when one repo has multiple workflows and only one should touch production. - Combination with attribute conditions. The provider’s
--attribute-conditioncan also enforce multiple predicates. A realistic production condition might require the repository owner, the repo name, the ref, and the environment to all match.
The exact attribute name for each of these is exposed through the mapping you configured on the provider. If an attribute is not in the mapping, you cannot use it in a binding. Re-run providers update-oidc to add new mappings when you need them.
Cleanup
WIF configuration itself costs nothing. The pool, the provider, the service account, and the IAM bindings are all free. The only cost is whatever the workflow actually calls (Artifact Registry storage, Cloud Run requests, BigQuery queries). If you set this up as a demo and want to tear it all down:
gcloud iam service-accounts delete github-deploy@$PROJECT_ID.iam.gserviceaccount.com --quiet
gcloud iam workload-identity-pools providers delete github-provider \
--location=global --workload-identity-pool=github-pool --quiet
gcloud iam workload-identity-pools delete github-pool --location=global --quiet
Deleted pools and providers enter a “soft-deleted” state for thirty days before being permanently removed. You can restore them during that window if you delete by accident. Pool names are not immediately reusable during the soft-delete period, so pick a different name if you need to rebuild quickly.
FAQ
What is the difference between Workload Identity Federation and Workload Identity Federation for GKE?
Workload Identity Federation is the general-purpose product for external workloads (GitHub Actions, AWS EC2, Azure VMs, on-prem OIDC) authenticating to GCP. You configure the pool and provider manually. Workload Identity Federation for GKE is a specialized version for Kubernetes ServiceAccounts inside a GKE cluster, where GCP manages the pool automatically at PROJECT_ID.svc.id.goog. They share the Security Token Service but are configured differently. This article covers the general external WIF product for GitHub Actions.
Do I still need a service account with WIF?
You need either a service account (chain impersonation mode) or direct resource access on the GitHub principal. The SA route is more widely supported because every GCP API honors IAM policies on service accounts. Direct access is newer and simpler but some older APIs do not yet accept principalSet member formats in their IAM policies. Start with SA impersonation, move to direct access where it works, skip entirely only if you know every API you call supports it.
How do I restrict access to a specific branch?
Bind the principal to attribute.ref matching refs/heads/main instead of the broader attribute.repository. Make sure the attribute mapping on the provider exposes attribute.ref=assertion.ref, otherwise the binding will not match. For production, combine branch restriction with GitHub Environment protection rules so only approved pushes to main can impersonate the deploy SA.
Can WIF work with self-hosted GitHub Actions runners?
Yes. Self-hosted runners receive the same OIDC token from GitHub as hosted runners, and the auth action works identically. The caveat is that self-hosted runners run in your own infrastructure, so they also have whatever identity that infrastructure has. If a runner VM has a workload identity of its own, a workflow could use either the GitHub WIF path or the VM’s native identity, which complicates the security model. Use WIF exclusively on self-hosted runners if you want a single consistent identity story.
How does this compare to OIDC with AWS?
Very similar conceptually. On AWS you create an IAM OIDC provider pointing at the GitHub issuer, an IAM role with a trust policy that matches the token subject claim, and the workflow uses aws-actions/configure-aws-credentials to assume the role with web identity. The GCP equivalent has a pool and a provider resource instead of a single OIDC provider, uses attribute mappings and conditions instead of trust policy conditions, and can chain through a service account or go direct. Both achieve the same outcome: short-lived cloud credentials derived from the GitHub OIDC token with no static keys.
What happens when GitHub rotates its OIDC signing keys?
Nothing on your side. GCP fetches the current signing keys from GitHub’s JWKS endpoint on every token verification and caches them for a short period. When GitHub rotates, GCP picks up the new keys automatically. You do not need to update anything in the provider configuration.
Where to Go Next
WIF for GitHub Actions is the entry point to credential-free CI/CD on GCP. The natural next steps are scoping bindings to specific branches or environments, automating the pool/provider creation with Terraform so every new repository gets a consistent setup, and applying the same pattern to GitLab CI (https://gitlab.com as issuer), CircleCI, and any other CI system with an OIDC story. On the GCP side, the same federated identity can push to Secret Manager, deploy to Cloud Run or GKE, publish images to Artifact Registry, or run BigQuery jobs. The official Workload Identity Federation docs, the google-github-actions/auth README, and our GKE Workload Identity guide (for the in-cluster equivalent) are the three references worth bookmarking.