Home / Security

Workload Identity Federation for GitHub Actions — Drop Service Account Keys Forever | 2026

Workload Identity Federation for GitHub Actions — Drop Service Account Keys Forever | 2026

Workload Identity Federation (WIF) lets GitHub Actions pipelines authenticate to Google Cloud using short-lived OIDC tokens instead of service account JSON keys, eliminating the biggest CI/CD credential leak risk with zero cost. This guide ships a production-ready Terraform module for WIF pool creation, OIDC provider registration, and least-privilege service account bindings so you never store a GCP key in GitHub Secrets again.

By Mateusz Chmielewski · Aug 6, 2026 · 14 min read

What Is Workload Identity Federation?

Workload Identity Federation (WIF) is a GCP IAM feature that allows external workloads such as GitHub Actions runners, GitLab CI, or Jenkins to authenticate to Google Cloud APIs using their native identity tokens (OIDC JWTs) rather than a downloaded service account JSON key file. The mechanism works via a Security Token Service (STS) exchange: the external workload presents a short-lived OIDC token signed by its identity provider. GCP validates the token against the registered WIF Provider, then issues a short-lived Google access token scoped to the impersonated service account. No long-lived secrets ever leave Google Cloud.

Think of WIF like a hotel key card system. Instead of giving every contractor a master copy of your office key (service account JSON), you call the front desk, they confirm the contractor ID badge (OIDC token from GitHub), and issue a temporary room key that expires at checkout (1-hour access token). The master key never leaves the building.

ConceptExplanationWhen to use
Workload Identity PoolA logical container that groups external identity providers. Analogous to an IAM Group for non-Google identities.One pool per GCP project is typically sufficient. Create per-environment pools for stricter separation.
Workload Identity Pool ProviderA registered OIDC or SAML provider within the pool. For GitHub Actions you add https://token.actions.githubusercontent.com as the OIDC issuer.One provider per external identity system (GitHub, GitLab, CircleCI, etc.).
Service Account ImpersonationA binding between an external identity and a GCP service account. The external identity is granted roles/iam.workloadIdentityUser on the SA.Bind at the repo-level or branch-level using attribute conditions for least privilege.
Attribute ConditionsCEL conditions that restrict which external identities can use a binding. For example, only the main branch of a specific repo.Always use attribute conditions in production. Never allow all repositories to impersonate a service account.
Short-lived Access TokenA Google OAuth2 access token generated after successful OIDC exchange. Valid for 1 hour, auto-refreshed by the google-github-actions/auth action.Used transparently by subsequent gcloud, gsutil, or Terraform commands in the same workflow job.

Why Replace Service Account Keys with WIF in Production?

The traditional approach for authenticating GitHub Actions to GCP requires downloading a service account JSON key and storing it as a GitHub Secret. This creates long-lived credentials that do not expire, are accessible to contributors with certain permissions, create compliance burden for rotation, and are blocked by the constraints/iam.disableServiceAccountKeyCreation organization policy recommended in GCP security hardening.

WIF eliminates all of the above risks by design. The google-github-actions/auth action obtains a short-lived OIDC token from GitHub's built-in Actions identity mechanism with no secret required, exchanges it with GCP STS for a 1-hour access token, and makes it available to downstream steps automatically. Combined with attribute conditions, WIF provides cryptographically verifiable, least-privilege, keyless authentication. See also the GCP Organization Policies guide for enforcing key-free authentication org-wide.

FeaturethisServicealtAaltB
Credential Lifetime60 minutes (auto-refreshed per job)Service Account JSON Key - years until manual rotationCloud Run managed identity - runtime only
Storage RequiredNone - no secrets in GitHubGitHub Secret (base64 JSON key)None (but Cloud Run only)
Audit Log ContextFull OIDC claims: repo, branch, ref, actor, shaService account email only, no pipeline contextN/A (Cloud Run context only)
Rotation RequiredNever - tokens are ephemeralManual rotation (compliance burden)Never (Cloud Run managed)
Org Policy CompatibleYes - works with disableServiceAccountKeyCreationNo - blocked by org policyYes - but Cloud Run only
Cost$0.00$0.00$0.00

Prerequisites

  • GCP Project with active billing and Owner or Security Admin IAM role
  • gcloud CLI v450.0+ authenticated (gcloud auth login --update-adc)
  • Terraform v1.5+ with google provider v5.0+
  • GitHub repository with Actions enabled
  • APIs to enable: iamcredentials.googleapis.com, sts.googleapis.com, cloudresourcemanager.googleapis.com

Step-by-Step Guide

Step 1: Enable Required GCP APIs

WIF requires the IAM Service Account Credentials API and the Security Token Service API. These are not enabled by default in new projects. Without these APIs, the OIDC token exchange step fails with SERVICE_DISABLED errors even if the WIF pool and provider are configured correctly.

gcloud services enable \
  iamcredentials.googleapis.com \
  sts.googleapis.com \
  cloudresourcemanager.googleapis.com \
  --project=YOUR_PROJECT_ID

Step 2: Create the Workload Identity Pool and OIDC Provider (Terraform Module)

This reusable Terraform module creates a WIF Pool and an OIDC Provider bound to GitHub's issuer with attribute mappings that expose GitHub JWT claims as filterable Google attributes for IAM conditions. The attribute mappings are the foundation of WIF security. They expose GitHub context fields (repo, ref, actor) as attributes so you can write CEL conditions in SA bindings to restrict which repositories and branches can impersonate which service accounts.

# Companion code: https://chmielewski.dev/tutorial/workload-identity-federation-github-actions-gcp
# modules/wif-github/main.tf

terraform {
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = ">= 5.0"
    }
  }
}

resource "google_iam_workload_identity_pool" "github" {
  project                   = var.project_id
  workload_identity_pool_id = var.pool_id
  display_name              = "GitHub Actions Pool"
  description               = "Identity pool for GitHub Actions OIDC authentication"

  lifecycle {
    prevent_destroy = true
  }
}

resource "google_iam_workload_identity_pool_provider" "github" {
  project                            = var.project_id
  workload_identity_pool_id          = google_iam_workload_identity_pool.github.workload_identity_pool_id
  workload_identity_pool_provider_id = var.provider_id
  display_name                       = "GitHub Actions OIDC Provider"
  description                        = "OIDC provider mapping GitHub Actions JWT claims to Google attributes"

  attribute_mapping = {
    "google.subject"             = "assertion.sub"
    "attribute.repository"       = "assertion.repository"
    "attribute.ref"              = "assertion.ref"
    "attribute.actor"            = "assertion.actor"
    "attribute.repository_owner" = "assertion.repository_owner"
  }

  attribute_condition = var.github_org != "" ? "assertion.repository_owner == '${var.github_org}'" : null

  oidc {
    issuer_uri        = "https://token.actions.githubusercontent.com"
    allowed_audiences = []
  }
}

Step 3: Define Module Variables and Outputs

Expose the pool and provider resource names as Terraform outputs. The provider_name output is the value you pass to google-github-actions/auth as workload_identity_provider. Using Terraform outputs means you never hardcode the WIF provider resource name which includes the project number in GitHub Actions YAML.

# modules/wif-github/variables.tf

variable "project_id" {
  description = "GCP Project ID where the WIF pool and provider will be created."
  type        = string
}

variable "pool_id" {
  description = "Unique ID for the Workload Identity Pool."
  type        = string
  default     = "github-actions-pool"

  validation {
    condition     = can(regex("^[a-z][a-z0-9-]{4,30}[a-z0-9]

chmielewski.dev | GCP Engineering & Architecture Blueprints

Insights, guides, and architectural blueprints for creators shipping on Google Cloud Platform.

quot;, var.pool_id)) error_message = "Pool ID must be 6-32 characters, lowercase letters, digits, and hyphens only." } } variable "provider_id" { description = "Unique ID for the OIDC Provider within the pool." type = string default = "github-provider" } variable "github_org" { description = "GitHub org to lock the attribute_condition. Leave empty to skip org-level restriction." type = string default = "" } # modules/wif-github/outputs.tf output "pool_name" { description = "Full resource name of the Workload Identity Pool." value = google_iam_workload_identity_pool.github.name } output "provider_name" { description = "Full resource name of the WIF provider. Pass to google-github-actions/auth as workload_identity_provider." value = google_iam_workload_identity_pool_provider.github.name } output "pool_id" { description = "Short pool ID for constructing principalSet strings." value = google_iam_workload_identity_pool.github.workload_identity_pool_id }

Step 4: Bind Service Accounts to GitHub Repos with Least-Privilege Conditions

For each service account a pipeline needs to impersonate, create a google_service_account_iam_member binding with roles/iam.workloadIdentityUser. Use principalSet for repo-scoped binding or add a CEL attribute condition to restrict to a specific branch. Without attribute conditions, ANY GitHub repository with a valid OIDC token could impersonate your service account if they know the provider resource name. This is the most dangerous WIF misconfiguration in production.

# modules/wif-github/sa_binding/main.tf

variable "project_id"         { type = string }
variable "pool_name"           { type = string }
variable "service_account_id" { type = string }
variable "repository_bindings" {
  type = list(object({
    owner      = string
    repo       = string
    branch_ref = optional(string, "*")
  }))
}

data "google_service_account" "sa" {
  account_id = var.service_account_id
  project    = var.project_id
}

# Any branch of the repo (build/test SAs)
resource "google_service_account_iam_member" "wif_repo" {
  for_each = {
    for b in var.repository_bindings :
    "${b.owner}/${b.repo}" => b
    if b.branch_ref == "*"
  }

  service_account_id = data.google_service_account.sa.name
  role               = "roles/iam.workloadIdentityUser"
  member             = "principalSet://iam.googleapis.com/${var.pool_name}/attribute.repository/${each.key}"
}

# Specific branch only (deploy SAs - main branch only)
resource "google_service_account_iam_member" "wif_branch" {
  for_each = {
    for b in var.repository_bindings :
    "${b.owner}/${b.repo}:${b.branch_ref}" => b
    if b.branch_ref != "*"
  }

  service_account_id = data.google_service_account.sa.name
  role               = "roles/iam.workloadIdentityUser"
  member             = "principalSet://iam.googleapis.com/${var.pool_name}/attribute.repository/${each.value.owner}/${each.value.repo}"

  condition {
    title       = "Branch: ${each.value.branch_ref}"
    description = "Only tokens from ${each.value.branch_ref} can impersonate this SA"
    expression  = "attribute.ref == '${each.value.branch_ref}'"
  }
}

Step 5: Root Module - Wire Pool and Bindings Together

Instantiate both modules from a root Terraform configuration. This is the complete copy-paste working example for your infrastructure repository. A consistent root module keeps WIF pool config, provider registration, and SA bindings co-located in version control ensuring all changes go through PRs and plan/apply review.

# environments/prod/wif.tf

locals {
  project_id = "your-project-id"
  github_org = "your-github-org"
}

module "wif" {
  source = "../../modules/wif-github"

  project_id  = local.project_id
  pool_id     = "github-actions-pool"
  provider_id = "github-provider"
  github_org  = local.github_org
}

# Terraform deployer SA - main branch only
module "wif_binding_terraform" {
  source = "../../modules/wif-github/sa_binding"

  project_id         = local.project_id
  pool_name          = module.wif.pool_name
  service_account_id = "terraform-deployer"

  repository_bindings = [
    {
      owner      = local.github_org
      repo       = "infrastructure"
      branch_ref = "refs/heads/main"
    }
  ]
}

# Container image builder SA - any branch
module "wif_binding_gcr" {
  source = "../../modules/wif-github/sa_binding"

  project_id         = local.project_id
  pool_name          = module.wif.pool_name
  service_account_id = "gcr-pusher"

  repository_bindings = [
    {
      owner      = local.github_org
      repo       = "backend-api"
      branch_ref = "*"
    }
  ]
}

output "wif_provider_name" {
  value       = module.wif.provider_name
  description = "Set as GCP_WIF_PROVIDER in GitHub Actions Variables"
}

output "terraform_deployer_sa_email" {
  value       = "terraform-deployer@${local.project_id}.iam.gserviceaccount.com"
  description = "Set as GCP_TERRAFORM_SA in GitHub Actions Variables"
}

Step 6: Configure GitHub Actions Workflows with Keyless Authentication

With WIF deployed, configure GitHub Actions to use google-github-actions/auth with workload_identity_provider and service_account instead of a JSON key. Two mandatory permissions must be set: id-token: write and contents: read. The id-token: write permission tells GitHub to mint an OIDC JWT for the workflow job. Without it the auth step fails. This permission must be explicitly granted since GitHub default is id-token: none.

# .github/workflows/deploy.yml
name: "Deploy to GCP"
on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    name: "Terraform Plan and Apply"
    runs-on: ubuntu-latest
    environment: production

    defaults:
      run:
        working-directory: environments/prod

    steps:
      - name: Checkout
        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2

      - name: Authenticate to Google Cloud
        id: auth
        uses: google-github-actions/auth@6fc4af4b145ae7821d527454aa9bd537d1f2dc5f  # v2.1.9
        with:
          workload_identity_provider: ${{ vars.GCP_WIF_PROVIDER }}
          service_account: ${{ vars.GCP_TERRAFORM_SA }}

      - name: Set up Cloud SDK
        uses: google-github-actions/setup-gcloud@6189d56e4096ee891640bb02ac264be376592d6a  # v2.1.4

      - name: Verify GCP Identity
        run: gcloud auth list --filter=status:ACTIVE

      - name: Set up Terraform
        uses: hashicorp/setup-terraform@b9cd54531c595073051f97edd88b2f273ed3e13a  # v3.1.2
        with:
          terraform_version: "1.9.0"

      - name: Terraform Init
        run: terraform init

      - name: Terraform Plan
        run: terraform plan -out=tfplan

      - name: Terraform Apply
        run: terraform apply tfplan

Verification and Health Check

Best Practices

  • Always restrict SA bindings to specific repositories and branches
  • SHA-pin all GitHub Actions to prevent supply chain attacks
  • Use GitHub Actions Variables for WIF config, not hardcoded values or Secrets
  • Apply least privilege - separate SA per pipeline purpose

Common Mistakes

  • {"errorCode":"OIDC_TOKEN_GENERATION_FAILED","symptoms":"Error in google-github-actions/auth: Unable to generate OIDC token - are the permissions id-token: write set?","rootCause":"The workflow is missing permissions: id-token: write. GitHub defaults to id-token: none.","fixCommand":"","code":"# Fix: Add permissions block\npermissions:\n id-token: write\n contents: read\n# In reusable workflows, the caller must also grant id-token: write\n","language":"yaml","filename":"","prevention":"Add a required workflow template via GitHub repository rulesets that enforces the id-token: write permissions block."}
  • {"errorCode":"PERMISSION_DENIED_SA_IMPERSONATION","symptoms":"Error creating access token: PERMISSION_DENIED: Permission iam.serviceAccounts.getAccessToken denied on resource","rootCause":"The WIF principal has not been granted roles/iam.workloadIdentityUser on the target SA, or the principalSet format does not match the OIDC token claims.","fixCommand":"gcloud iam service-accounts add-iam-policy-binding \\\n deployer@PROJECT_ID.iam.gserviceaccount.com \\\n --member=\"principalSet://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/github-actions-pool/attribute.repository/myorg/my-repo\" \\\n --role=\"roles/iam.workloadIdentityUser\"\n","code":"# Debug: decode the OIDC token claims to verify subject format\n- name: Debug OIDC Claims\n run: |\n TOKEN=$(curl -sH \"Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN\" \\\n \"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${WIF_PROVIDER}\")\n echo \"$TOKEN\" | jq -r '.value' | cut -d. -f2 | base64 -d 2>/dev/null | jq '{sub, repository, ref, actor}'\n env:\n WIF_PROVIDER: ${{ vars.GCP_WIF_PROVIDER }}\n","language":"bash","filename":"","prevention":"Verify attribute.repository matches your GitHub owner/repo exactly - it is case-sensitive. Use terraform output -raw pool_name to get the full resource name with correct project number."}
  • {"errorCode":"INVALID_ARGUMENT_AUDIENCE","symptoms":"invalid_request: Invalid audience - not found in token audiences","rootCause":"A custom audience is set in the workflow but the provider allowed_audiences list is empty (default uses provider resource name).","fixCommand":"","code":"# Wrong: custom audience does not match provider config\n- uses: google-github-actions/auth@v2\n with:\n workload_identity_provider: ${{ vars.GCP_WIF_PROVIDER }}\n audience: \"https://custom.example.com\" # Remove this line\n\n# Correct: no audience parameter\n- uses: google-github-actions/auth@v2\n with:\n workload_identity_provider: ${{ vars.GCP_WIF_PROVIDER }}\n","language":"yaml","filename":"","prevention":"Only set a custom audience if you have configured allowed_audiences in google_iam_workload_identity_pool_provider. For most setups omit the audience parameter entirely."}

Cost Analysis

Workload Identity Federation itself is completely free on GCP. The only potential cost is Cloud Audit Logs ingestion beyond the 50 GB/month free tier.

Featuremetriccost1kcost10kcost100kcost1m
WIF Token Exchange (STS API)$0.00$0.00$0.00$0.00
Service Account Impersonation (IAM)$0.00$0.00$0.00$0.00
Cloud Audit Logs (WIF auth events)$0.00$0.00~$0.50~$5.00

References

Browse all tutorials