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:
- A Workload Identity Pool (
google_iam_workload_identity_pool) — a namespace that groups external identities. - A Pool Provider (
google_iam_workload_identity_pool_provider) — defines which external issuer is trusted (anoidc { issuer_uri }block for GitHub/OIDC, or anaws { account_id }block for AWS), how its token claims map to Google attributes viaattribute_mapping, and an optionalattribute_conditionthat rejects tokens that do not match (e.g. tokens from the wrong repository or branch). - A Service Account (
google_service_account) — the identity the external workload ultimately acts as once federated. - A
workloadIdentityUserbinding (google_service_account_iam_member) — grants a specific externalprincipalSet(e.g. “any token from repomy-org/my-repo”) the right to impersonate that service account.
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
- You run CI/CD in GitHub Actions (or GitLab, CircleCI, Azure DevOps) that needs to deploy to GCP, and you want to delete every
GOOGLE_CREDENTIALSJSON key from your repo secrets. - You have AWS workloads (Lambda, EC2, EKS IRSA) that must read from GCS, write to BigQuery, or publish to Pub/Sub, and you want them to use their existing AWS identity instead of a smuggled GCP key.
- You need per-repository or per-branch scoping so that only
mainofmy-org/payments-infracan impersonate the deploy SA — enforced by anattribute_condition, not by hoping nobody copies a key. - You are passing a security audit that flags long-lived service account keys, and you need a paved-road module so every team federates the same way with the same guardrails.
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_CREDENTIALSsecret, 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 config — live/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 config — live/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
- Never export a service account key again. That is the entire point — this module creates the SA with no key and grants
workloadIdentityUserto aprincipalSet. If you find yourself generating agoogle_service_account_key, you have defeated the design. - Always set an
attribute_condition. Without it, every token from the issuer is accepted — meaning any GitHub repo on the planet could impersonate your SA if it knows the provider name. Scope to your org (assertion.repository_owner == 'my-org') at minimum, and ideally to specific repos/branches. - Scope the binding tightly with mapped attributes. Bind
roles/iam.workloadIdentityUsertoattribute.repository/<org>/<repo>(orattribute.reffor a branch), not to the whole pool. The narrower theprincipalSet, the smaller the blast radius. - Keep the impersonated SA least-privilege. Grant only the project roles the workload truly needs via
sa_project_roles; a CI deployer rarely needsroles/owner. Use one SA per workload so a compromise is contained. - Use one pool per trust boundary. Group GitHub under a
github-actionspool and AWS under its own; don’t mix unrelated issuers in a single pool. Per-environment pools (dev/prod) keep production federation isolated. - Wire the outputs straight into CI variables. Publish
provider_nameandservice_account_emailas repository/org variables — never as secrets, since neither is sensitive — and reference them ingoogle-github-actions/authso the workflow stays keyless and self-documenting.