Workload Identity Federation
How GitHub Actions authenticates to GCP without service account keys using OIDC-based Workload Identity Federation.
Workload Identity Federation (WIF) allows GitHub Actions to authenticate to GCP without long-lived service account keys. Instead, GitHub Actions presents an OIDC token to GCP, which exchanges it for short-lived credentials scoped to a specific service account.
How It Works
GitHub Actions runner
|
| (1) Request OIDC token from GitHub
v
GitHub OIDC provider (token.actions.githubusercontent.com)
|
| (2) JWT with repository, ref, actor claims
v
GCP Workload Identity Pool ("github-actions")
|
| (3) Validate token, check attribute_condition
v
GCP Workload Identity Provider ("github-oidc")
|
| (4) Exchange for short-lived SA credentials
v
Service Account (github-actions-ci@trovella-shared)
|
| (5) Use credentials for cross-project operations
v
trovella-prod / trovella-staging resources
Terraform Configuration
The WIF resources are defined in infra/modules/wif/main.tf and instantiated from infra/environments/shared/main.tf.
Pool and Provider
resource "google_iam_workload_identity_pool" "github" {
project = var.project_id
workload_identity_pool_id = "github-actions"
display_name = "GitHub Actions"
}
resource "google_iam_workload_identity_pool_provider" "github" {
project = var.project_id
workload_identity_pool_id = "github-actions"
workload_identity_pool_provider_id = "github-oidc"
attribute_mapping = {
"google.subject" = "assertion.sub"
"attribute.actor" = "assertion.actor"
"attribute.repository" = "assertion.repository"
"attribute.ref" = "assertion.ref"
}
attribute_condition = "assertion.repository == \"kyleolson512/Trovella\""
oidc {
issuer_uri = "https://token.actions.githubusercontent.com"
}
}
The attribute_condition restricts token exchange to workflows running in the kyleolson512/Trovella repository only. Forked repositories or other repositories cannot obtain credentials.
Service Account
resource "google_service_account" "github_actions" {
project = var.project_id
account_id = "github-actions-ci"
display_name = "GitHub Actions CI/CD"
}
The service account lives in trovella-shared. It has roles/iam.workloadIdentityUser bound to the WIF pool, allowing token exchange.
Cross-Project IAM Grants
The service account has permissions across all three projects:
| Role | Projects | Purpose |
|---|---|---|
roles/editor | prod, staging, shared | Deploy and manage resources |
roles/secretmanager.secretAccessor | prod, staging | Read secrets during build and deploy |
roles/iap.tunnelResourceAccessor | prod, staging | SSH to VMs via IAP tunnel |
roles/compute.instanceAdmin.v1 | prod, staging | SSH access and instance management |
The shared project only gets roles/editor (for Artifact Registry pushes). It does not get secret accessor or IAP tunnel roles because there are no secrets or VMs in the shared project.
CI Workflow Usage
The CI workflow (.github/workflows/ci.yml) uses WIF in three jobs:
migrate-prod
permissions:
contents: read
id-token: write
steps:
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ vars.WIF_PROVIDER }}
service_account: ${{ vars.WIF_SERVICE_ACCOUNT }}
The id-token: write permission is required for the runner to request an OIDC token from GitHub. The vars.WIF_PROVIDER and vars.WIF_SERVICE_ACCOUNT are stored as GitHub repository variables (not secrets -- they are not sensitive).
After authentication, the job can:
- Read
DATABASE_URLfrom Secret Manager - Start Cloud SQL Auth Proxy (which uses the SA's
cloudsql.clientrole)
build-push
Authenticates to push Docker images to Artifact Registry in trovella-shared:
- run: gcloud auth configure-docker us-central1-docker.pkg.dev --quiet
Also reads SENTRY_DSN from Secret Manager for the Docker build args.
deploy-prod
Authenticates to SSH into the production VM via IAP tunnel:
- run: |
gcloud compute scp ... --tunnel-through-iap
gcloud compute ssh ... --tunnel-through-iap
Outputs
The WIF module exports three values used as GitHub repository variables:
| Output | Description | GitHub Variable |
|---|---|---|
provider_name | Full resource name of the OIDC provider | WIF_PROVIDER |
service_account_email | Email of the CI service account | WIF_SERVICE_ACCOUNT |
pool_name | Full resource name of the identity pool | (not used in CI) |
Security Properties
- No long-lived keys -- credentials are generated per-workflow-run and expire automatically
- Repository-scoped -- only
kyleolson512/Trovellacan obtain credentials - Least privilege (by project) -- the SA has different roles per project; shared project has no secret access
- Audit trail -- every token exchange is logged in Cloud Audit Logs
Quota Project Gotcha
The WIF service account lives in trovella-shared, so API calls made with its credentials are attributed to the shared project by default. This caused failures when the Cloud SQL Auth Proxy tried to call the Cloud SQL Admin API -- the API was enabled in trovella-prod but not in trovella-shared.
The fix is the --quota-project trovella-prod flag on the proxy. See Data & Storage -- Migrations -- CI Deployment for the full hardening history.