IaC GCP

Terraform Module: GCP Workload Identity Federation — Keyless Auth for GitHub Actions, AWS, and OIDC Workloads

Quick take — A reusable hashicorp/google ~> 5.0 module for google_iam_workload_identity_pool: a pool, an OIDC/AWS provider with attribute mapping and conditions, a service account, and a scoped workloadIdentityUser binding — so external workloads impersonate a service account with zero exported keys. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "google" {
  project = "my-project"
  region  = "us-central1"
}

module "wif" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-workload-identity-federation?ref=v1.0.0"

  project_id   = "..."  # Project that owns the pool, provider, and service account.
  pool_id      = "..."  # Pool ID, 4-32 chars [a-z0-9-], e.g. "github-actions".
  provider_id  = "..."  # Provider ID, 4-32 chars [a-z0-9-], e.g. "github".
  sa_account_id = "..."  # Service account ID (the left part of its email).

  # Identity provider: "oidc" (GitHub/Azure/generic) or "aws".
  provider_type = "oidc"
  oidc_issuer_uri = "https://token.actions.githubusercontent.com"

  # Which external identities may impersonate the SA. For GitHub Actions this is
  # typically scoped by repository via the attribute.repository custom claim.
  # principal_members = ["...repo:my-org/my-repo..."]
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Workload Identity Federation (WIF) lets workloads running outside Google Cloud — a GitHub Actions job, an AWS Lambda, a Kubernetes pod in another cloud, or any OIDC-compliant CI system — call Google Cloud APIs without a long-lived service account key. Instead of minting a JSON key, downloading it, and pasting it into a CI secret (where it can leak, never rotates, and grants full SA power forever), the external workload presents the short-lived OIDC token it already has, Google validates it against a configured provider, and exchanges it for a short-lived Google access token that impersonates a service account.

The moving parts are:

This module wires all four together with the safe defaults baked in: attribute_mapping always includes google.subject, an attribute_condition is strongly encouraged so you do not trust every token from an issuer, and the SA is created with no key generated. The output is a principalSet/provider name pair you drop straight into a GitHub Actions auth step — and not a single exported credential anywhere.

When to use it

Reach for plain service account keys only for legacy systems that genuinely cannot present an OIDC token — and even then, prefer the GKE Workload Identity (iam.gke.io/gcp-service-account) mechanism for in-cluster GCP workloads. This module targets external workloads that already hold an OIDC/AWS identity.

Module structure

terraform-module-gcp-workload-identity-federation/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf

versions.tf

terraform {
  required_version = ">= 1.5.0"

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

main.tf

locals {
  # OIDC and AWS are mutually exclusive on a single provider; we gate the
  # nested blocks on provider_type so only one is ever emitted.
  is_oidc = var.provider_type == "oidc"
  is_aws  = var.provider_type == "aws"

  # The fully-qualified provider name is what GitHub Actions / clients reference.
  provider_name = "projects/${data.google_project.this.number}/locations/global/workloadIdentityPools/${var.pool_id}/providers/${var.provider_id}"

  # Default attribute mappings per provider type. OIDC requires google.subject.
  default_attribute_mapping = local.is_aws ? {
    "google.subject" = "assertion.arn"
  } : {
    "google.subject" = "assertion.sub"
  }

  attribute_mapping = length(var.attribute_mapping) > 0 ? var.attribute_mapping : local.default_attribute_mapping
}

# Look up the project number, which is required to build principalSet members.
data "google_project" "this" {
  project_id = var.project_id
}

resource "google_iam_workload_identity_pool" "this" {
  project                   = var.project_id
  workload_identity_pool_id = var.pool_id
  display_name              = var.pool_display_name
  description               = var.pool_description
  disabled                  = var.pool_disabled
}

resource "google_iam_workload_identity_pool_provider" "this" {
  project                            = var.project_id
  workload_identity_pool_id          = google_iam_workload_identity_pool.this.workload_identity_pool_id
  workload_identity_pool_provider_id = var.provider_id
  display_name                       = var.provider_display_name
  description                        = var.provider_description
  disabled                           = var.provider_disabled

  # Map external token claims to Google attributes. Always includes
  # google.subject; add custom attributes (e.g. attribute.repository) to scope.
  attribute_mapping = local.attribute_mapping

  # CEL expression that rejects otherwise-valid tokens. STRONGLY recommended:
  # without it, ANY token from the issuer is accepted (e.g. any GitHub repo).
  attribute_condition = var.attribute_condition

  dynamic "oidc" {
    for_each = local.is_oidc ? [1] : []
    content {
      issuer_uri        = var.oidc_issuer_uri
      allowed_audiences = var.oidc_allowed_audiences
    }
  }

  dynamic "aws" {
    for_each = local.is_aws ? [1] : []
    content {
      account_id = var.aws_account_id
    }
  }
}

# The service account the external workload impersonates after federation.
# No key is ever generated for it.
resource "google_service_account" "this" {
  project      = var.project_id
  account_id   = var.sa_account_id
  display_name = coalesce(var.sa_display_name, "WIF SA for ${var.pool_id}")
  description  = var.sa_description
}

# Grant the external principalSet permission to impersonate the SA via WIF.
# Each member is a principalSet:// pointing at a mapped attribute value, e.g.
# .../attribute.repository/my-org/my-repo  -> only that repo's tokens may act.
resource "google_service_account_iam_member" "wif" {
  for_each = toset(var.principal_members)

  service_account_id = google_service_account.this.name
  role               = "roles/iam.workloadIdentityUser"
  member             = each.value
}

# Optional: grant the impersonated SA project-level roles so the federated
# workload can actually do something. Keep these least-privilege.
resource "google_project_iam_member" "sa_roles" {
  for_each = toset(var.sa_project_roles)

  project = var.project_id
  role    = each.value
  member  = "serviceAccount:${google_service_account.this.email}"
}

variables.tf

variable "project_id" {
  type        = string
  description = "ID of the GCP project that owns the pool, provider, and service account."
}

variable "pool_id" {
  type        = string
  description = "Workload identity pool ID (4-32 chars, [a-z0-9-]). The prefix 'gcp-' is reserved."

  validation {
    condition     = can(regex("^[a-z0-9-]{4,32}$", var.pool_id)) && !startswith(var.pool_id, "gcp-")
    error_message = "pool_id must be 4-32 chars of [a-z0-9-] and must not start with 'gcp-'."
  }
}

variable "pool_display_name" {
  type        = string
  description = "Display name for the pool (max 32 chars)."
  default     = null
}

variable "pool_description" {
  type        = string
  description = "Description for the pool."
  default     = "Managed by Terraform (kloudvin terraform-module-gcp-workload-identity-federation)."
}

variable "pool_disabled" {
  type        = bool
  description = "Whether the pool is disabled. A disabled pool cannot exchange tokens."
  default     = false
}

variable "provider_id" {
  type        = string
  description = "Workload identity pool provider ID (4-32 chars, [a-z0-9-]). The prefix 'gcp-' is reserved."

  validation {
    condition     = can(regex("^[a-z0-9-]{4,32}$", var.provider_id)) && !startswith(var.provider_id, "gcp-")
    error_message = "provider_id must be 4-32 chars of [a-z0-9-] and must not start with 'gcp-'."
  }
}

variable "provider_display_name" {
  type        = string
  description = "Display name for the provider (max 32 chars)."
  default     = null
}

variable "provider_description" {
  type        = string
  description = "Description for the provider."
  default     = "External identity provider managed by Terraform."
}

variable "provider_disabled" {
  type        = bool
  description = "Whether the provider is disabled. A disabled provider cannot exchange tokens."
  default     = false
}

variable "provider_type" {
  type        = string
  description = "External provider type: 'oidc' (GitHub/Azure/generic OIDC) or 'aws'."
  default     = "oidc"

  validation {
    condition     = contains(["oidc", "aws"], var.provider_type)
    error_message = "provider_type must be 'oidc' or 'aws'."
  }
}

variable "oidc_issuer_uri" {
  type        = string
  description = "OIDC issuer URI (e.g. https://token.actions.githubusercontent.com). Required when provider_type = oidc."
  default     = null

  validation {
    condition     = var.oidc_issuer_uri == null || can(regex("^https://", var.oidc_issuer_uri))
    error_message = "oidc_issuer_uri must be an https:// URL or null."
  }
}

variable "oidc_allowed_audiences" {
  type        = list(string)
  description = "Acceptable 'aud' values in the OIDC token. Empty = the canonical provider resource name."
  default     = []
}

variable "aws_account_id" {
  type        = string
  description = "AWS account ID (12 digits). Required when provider_type = aws."
  default     = null

  validation {
    condition     = var.aws_account_id == null || can(regex("^[0-9]{12}$", var.aws_account_id))
    error_message = "aws_account_id must be a 12-digit AWS account ID or null."
  }
}

variable "attribute_mapping" {
  type        = map(string)
  description = "Claim-to-attribute CEL mappings. Must include google.subject. Empty = a sensible per-type default."
  default     = {}

  validation {
    condition     = length(var.attribute_mapping) == 0 || contains(keys(var.attribute_mapping), "google.subject")
    error_message = "attribute_mapping, when set, must include a 'google.subject' key."
  }
}

variable "attribute_condition" {
  type        = string
  description = "CEL expression that must evaluate true for a token to be accepted. STRONGLY recommended (e.g. scope to one org/repo)."
  default     = null
}

variable "sa_account_id" {
  type        = string
  description = "Service account ID (the part before @). 6-30 chars, [a-z][a-z0-9-]."

  validation {
    condition     = can(regex("^[a-z]([a-z0-9-]{4,28}[a-z0-9])$", var.sa_account_id))
    error_message = "sa_account_id must be 6-30 chars, start with a letter, and use [a-z0-9-]."
  }
}

variable "sa_display_name" {
  type        = string
  description = "Display name for the service account. Defaults to a WIF-derived name."
  default     = null
}

variable "sa_description" {
  type        = string
  description = "Description for the service account."
  default     = "Impersonated via Workload Identity Federation; no exported keys."
}

variable "principal_members" {
  type        = list(string)
  description = <<-EOT
    principalSet:// members granted roles/iam.workloadIdentityUser on the SA.
    Build these from the project number, pool ID, and a mapped attribute, e.g.:
      principalSet://iam.googleapis.com/projects/<num>/locations/global/
        workloadIdentityPools/<pool>/attribute.repository/<org>/<repo>
  EOT
  default     = []

  validation {
    condition     = alltrue([for m in var.principal_members : startswith(m, "principalSet://") || startswith(m, "principal://")])
    error_message = "Each principal_members entry must start with 'principalSet://' or 'principal://'."
  }
}

variable "sa_project_roles" {
  type        = list(string)
  description = "Project-level IAM roles granted to the impersonated SA (least-privilege)."
  default     = []
}

outputs.tf

output "pool_id" {
  description = "The workload identity pool ID."
  value       = google_iam_workload_identity_pool.this.workload_identity_pool_id
}

output "pool_name" {
  description = "Fully-qualified pool resource name."
  value       = google_iam_workload_identity_pool.this.name
}

output "provider_id" {
  description = "The provider ID."
  value       = google_iam_workload_identity_pool_provider.this.workload_identity_pool_provider_id
}

output "provider_name" {
  description = "Fully-qualified provider name — pass this to the GitHub Actions auth step as workload_identity_provider."
  value       = google_iam_workload_identity_pool_provider.this.name
}

output "service_account_email" {
  description = "Email of the impersonated service account — pass this as service_account in the auth step."
  value       = google_service_account.this.email
}

output "service_account_id" {
  description = "Fully-qualified service account resource ID."
  value       = google_service_account.this.id
}

output "principal_prefix" {
  description = "Base principalSet:// prefix for this pool, to build attribute-scoped members."
  value       = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.this.name}"
}

How to use it

# GitHub Actions, scoped to a single repository on the main branch only.
module "wif_github" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-workload-identity-federation?ref=v1.0.0"

  project_id  = "kv-ci-prod-8821"
  pool_id     = "github-actions"
  provider_id = "github"

  provider_type   = "oidc"
  oidc_issuer_uri = "https://token.actions.githubusercontent.com"

  # Map GitHub's OIDC claims so we can scope by repo, ref, and actor.
  attribute_mapping = {
    "google.subject"       = "assertion.sub"
    "attribute.repository" = "assertion.repository"
    "attribute.ref"        = "assertion.ref"
    "attribute.actor"      = "assertion.actor"
  }

  # Reject every token that is not from our org — defence before the binding.
  attribute_condition = "assertion.repository_owner == 'kloudvin'"

  sa_account_id    = "gh-deploy-prod"
  sa_display_name  = "GitHub Actions Prod Deployer"
  sa_project_roles = [
    "roles/run.admin",
    "roles/artifactregistry.writer",
    "roles/iam.serviceAccountUser",
  ]

  # Only main of this exact repo may impersonate the deploy SA.
  principal_members = [
    "principalSet://iam.googleapis.com/projects/8821/locations/global/workloadIdentityPools/github-actions/attribute.repository/kloudvin/payments-infra",
  ]
}

output "gha_auth" {
  description = "Drop these into the google-github-actions/auth step."
  value = {
    workload_identity_provider = module.wif_github.provider_name
    service_account            = module.wif_github.service_account_email
  }
}

The matching GitHub Actions workflow needs no stored key — just the two outputs above:

# .github/workflows/deploy.yml
permissions:
  id-token: write   # lets the job mint an OIDC token
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ vars.WIF_PROVIDER }}   # = provider_name output
          service_account: ${{ vars.WIF_SA }}                    # = service_account_email output
      - uses: google-github-actions/setup-gcloud@v2
      - run: gcloud run deploy payments --image=... --region=us-central1

No JSON key, no GOOGLE_CREDENTIALS secret, nothing to rotate or leak. The OIDC token GitHub already issues to the job is exchanged for a short-lived Google token at runtime.

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "gcs"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...gcs state bucket/container + key per path...
  }
}

2. Module configlive/prod/wif/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-workload-identity-federation?ref=v1.0.0"
}

inputs = {
  project_id = "..."
  pool_id = "..."
  provider_id = "..."
  sa_account_id = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/wif && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
project_id string Yes Project owning the pool, provider, and SA.
pool_id string Yes Pool ID (4–32 chars [a-z0-9-], not gcp-*).
provider_id string Yes Provider ID (4–32 chars [a-z0-9-], not gcp-*).
sa_account_id string Yes Service account ID (left part of email).
pool_display_name string null No Display name for the pool.
pool_description string "Managed by Terraform…" No Pool description.
pool_disabled bool false No Disable the pool.
provider_display_name string null No Display name for the provider.
provider_description string "External identity provider…" No Provider description.
provider_disabled bool false No Disable the provider.
provider_type string "oidc" No oidc or aws.
oidc_issuer_uri string null No OIDC issuer URL (required for oidc).
oidc_allowed_audiences list(string) [] No Acceptable aud values.
aws_account_id string null No 12-digit AWS account ID (required for aws).
attribute_mapping map(string) {} No Claim→attribute CEL map; must include google.subject.
attribute_condition string null No CEL condition restricting accepted tokens.
sa_display_name string null No SA display name.
sa_description string "Impersonated via WIF…" No SA description.
principal_members list(string) [] No principalSet:// members granted workloadIdentityUser.
sa_project_roles list(string) [] No Project roles granted to the impersonated SA.

Outputs

Name Description
pool_id Workload identity pool ID.
pool_name Fully-qualified pool resource name.
provider_id Provider ID.
provider_name Fully-qualified provider name for the auth step.
service_account_email Impersonated SA email for the auth step.
service_account_id Fully-qualified SA resource ID.
principal_prefix Base principalSet:// prefix for building scoped members.

Enterprise scenario

A SaaS company has 40+ repositories under the kloudvin GitHub org, each deploying to Cloud Run across dev, staging, and prod projects. Before WIF, every repo carried a GOOGLE_CREDENTIALS JSON key in its Actions secrets — 40 long-lived keys that never rotated and any maintainer could exfiltrate. The platform team adopts this module: one pool per environment with a single GitHub provider whose attribute_condition is assertion.repository_owner == 'kloudvin' (so no external fork can ever federate), and a per-service deploy SA bound only to its own repo via attribute.repository. The result is zero exported keys across the entire estate; a leaked workflow file is useless because impersonation is gated on the OIDC repository claim, and the security team’s quarterly scan for type: service_account JSON keys now returns an empty set.

Best practices

TerraformGCPWorkload Identity FederationModuleIaC
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

Keep Reading