Secretless CI/CD deployments (OIDC federation)
A GitHub Actions job deploys to the cloud with Terraform using its OIDC token, with no client secrets or access keys stored in the repository.
Problem
The pipeline needs credentials to deploy, and a key stored as a repo secret is the most exposed long-lived credential an organization has: it never expires on its own, gets copied across repos, and nobody knows who used it. Requirements: zero static secrets, trust scoped to one repository and one specific environment (not any branch or any repo in the org), least-privilege permissions on a scope, and tokens that last minutes.
The topology is the same on every cloud; what changes is what implements each role.
Federated trust
GCP
Workload Identity Federation (pool + provider OIDC)
attribute_condition on repository_owner_id, repository_id and environment
Coming from Azure: there this role is Federated identity credential (workload identity federation).
Coming from AWS: there this role is IAM OIDC identity provider. Trust is a CEL condition on mapped attributes, and it's best scoped by repository_id (immutable) rather than the name.
4 gotchas in this role
Azure
Federated identity credential (workload identity federation)
One per environment: GitHub issuer + exact subject + audience api://AzureADTokenExchange
Coming from AWS: there this role is IAM OIDC identity provider. There's no trust policy with wildcards: each environment, branch or tag is a credential with an exact subject, up to 20 per identity.
Coming from GCP: there this role is Workload Identity Federation (pool + provider OIDC). There's no CEL condition: each environment, branch or tag is a credential with an exact subject, up to 20 per identity.
4 gotchas in this role
- The subject must match exactly, with no wildcards, and there are at most 20 credentials per identity
- A freshly created credential can fail with AADSTS70021 for a few minutes
- Creating several credentials in parallel on the same identity returns 409
- Since 2026-07-15, new, renamed or transferred repos use a different sub format
Deployment identity
GCP
Service Account (impersonada)
roles/iam.workloadIdentityUser only for the repository's principalSet
Coming from Azure: there this role is User-assigned Managed Identity.
Coming from AWS: there this role is IAM Role.
1 gotcha in this role
Azure
User-assigned Managed Identity
No secrets or certificates; the workflow only knows client-id and tenant-id
Coming from AWS: there this role is IAM Role.
Coming from GCP: there this role is Service Account (impersonada).
No fine print in this role.
Least-privilege permissions
GCP
IAM
Scoped project roles; never owner/editor
Coming from Azure: there this role is Azure RBAC.
Coming from AWS: there this role is IAM policies attached to the role.
2 gotchas in this role
Azure
Azure RBAC
One role assignment on the target resource group
Coming from AWS: there this role is IAM policies attached to the role.
Coming from GCP: there this role is IAM.
1 gotcha in this role
Google Cloud
Implementation
- Federated trust
- Workload Identity Federation (pool + provider OIDC)attribute_condition on repository_owner_id, repository_id and environmentComing from Azure: there this role is Federated identity credential (workload identity federation).Coming from AWS: there this role is IAM OIDC identity provider. Trust is a CEL condition on mapped attributes, and it's best scoped by repository_id (immutable) rather than the name.
- Deployment identity
- Service Account (impersonada)roles/iam.workloadIdentityUser only for the repository's principalSetComing from Azure: there this role is User-assigned Managed Identity.Coming from AWS: there this role is IAM Role.
- Least-privilege permissions
- IAMScoped project roles; never owner/editorComing from Azure: there this role is Azure RBAC.Coming from AWS: there this role is IAM policies attached to the role.
Cost model
Scales to zero
No fixed cost
Workload Identity Federation, service accounts and IAM are free. You only pay for what the pipeline deploys.
Monthly floor with no traffic
USD 0/ mo
USD 0: no components with a fixed cost.
Fine print · 5 gotchas
Without attribute_condition, GitHub's shared issuer lets any repo in
GitHub uses one issuer for all its customers, so validating the issuer isn't enough. Google requires a condition restricting tokens to your organization or repository. In Terraform the field is listed as optional and, without a condition, every valid credential from the issuer is accepted. Also, every claim you use in the condition or in a principalSet must be mapped in attribute_mapping.
Condition on numeric IDs, not on org and repo names
An organization or repository name can be released and registered again: if your condition says repository == 'org/repo', the next owner of that name inherits the trust. Google recommends immutable attributes, and GitHub sends repository_id and repository_owner_id in every token. It's the same reason GitHub changed the sub format for repos created since 2026-07-15.
Direct access without a service account doesn't work with every service
You can grant roles directly to the federated principal, without a service account. But some APIs have limitations with federated identities, and for those you have to impersonate a service account. This module impersonates: it works with everything and leaves a single place to audit permissions. For that path Google asks you to enable four APIs: IAM, Resource Manager, Service Account Credentials and Security Token Service.
Principals use the project number, and permissions take minutes to propagate
When granting roles to external identities you use the project number, not the project ID: the principalSet is principalSet://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/.... Also, an IAM change usually applies within 2 minutes but can take 7 or more.
A deleted pool locks its ID for about 30 days
Pools and providers are soft-deleted and only permanently removed after ~30 days. Meanwhile you can't create another with the same ID, so a terraform destroy followed by apply with the same pool_id fails. The way out is to restore it (undelete) and import it, or use a different ID. For ephemeral environments, generate the ID with a suffix.
Terraform
main.tf
# Despliegue desde CI sin secretos — GCP
#
# Workload Identity Pool + provider OIDC de GitHub con una condición CEL sobre
# IDs inmutables, y una service account de despliegue que solo puede
# impersonar el principalSet de ese repositorio.
locals {
# Las cuatro APIs que pide la guía de Google para pipelines.
apis = ["iam.googleapis.com", "cloudresourcemanager.googleapis.com", "iamcredentials.googleapis.com", "sts.googleapis.com"]
envs_cel = join(", ", [for e in sort(tolist(var.environments)) : "'${e}'"])
# Sin condición, cualquier repo de GitHub podría obtener tokens: el issuer es compartido.
attribute_condition = "assertion.repository_owner_id == '${var.github.owner_id}' && assertion.repository_id == '${var.github.repository_id}' && assertion.environment in [${local.envs_cel}]"
attribute_mapping = {
"google.subject" = "assertion.sub"
"attribute.repository_id" = "assertion.repository_id"
"attribute.repository_owner_id" = "assertion.repository_owner_id"
"attribute.environment" = "assertion.environment"
}
}
resource "google_project_service" "required" {
for_each = toset(local.apis)
project = var.project_id
service = each.value
disable_on_destroy = false
}
resource "google_iam_workload_identity_pool" "github" {
project = var.project_id
workload_identity_pool_id = var.pool_id
display_name = "GitHub Actions"
description = "Tokens OIDC de GitHub Actions"
depends_on = [google_project_service.required]
}
resource "google_iam_workload_identity_pool_provider" "github" {
#checkov:skip=CKV_GCP_125:El check solo reconoce condiciones sobre assertion.sub con nombres; esta condición usa repository_owner_id y repository_id (inmutables), que es lo que recomienda Google.
project = var.project_id
workload_identity_pool_id = google_iam_workload_identity_pool.github.workload_identity_pool_id
workload_identity_pool_provider_id = "github-oidc"
display_name = "GitHub OIDC"
attribute_mapping = local.attribute_mapping
attribute_condition = local.attribute_condition
oidc {
issuer_uri = "https://token.actions.githubusercontent.com"
}
}
resource "google_service_account" "deploy" {
project = var.project_id
account_id = "gha-deploy"
display_name = "GitHub Actions deploy"
}
# Solo el principalSet de este repositorio puede impersonar la SA.
resource "google_service_account_iam_member" "workload_identity_user" {
service_account_id = google_service_account.deploy.name
role = "roles/iam.workloadIdentityUser"
member = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.github.name}/attribute.repository_id/${var.github.repository_id}"
}
resource "google_project_iam_member" "deploy" {
for_each = var.deploy_roles
project = var.project_id
role = each.value
member = "serviceAccount:${google_service_account.deploy.email}"
}variables.tf
variable "project_id" {
description = "Proyecto donde viven el pool y la service account de despliegue."
type = string
}
variable "region" {
description = "Región por defecto del provider."
type = string
default = "us-central1"
}
variable "pool_id" {
description = "ID del workload identity pool (4-32, minúsculas). Un pool borrado bloquea su ID ~30 días (gotcha: pool-soft-delete-30-days)."
type = string
default = "github"
validation {
condition = can(regex("^[a-z0-9-]{4,32}$", var.pool_id)) && !startswith(var.pool_id, "gcp-")
error_message = "pool_id: 4-32 caracteres [a-z0-9-], sin el prefijo reservado gcp-."
}
}
variable "github" {
description = <<-EOT
Repositorio que puede desplegar. En GCP la condición se arma sobre los IDs numéricos (inmutables), no sobre
los nombres: un nombre de org o repo reciclado no hereda la confianza.
EOT
type = object({
owner_id = number
repository_id = number
})
}
variable "environments" {
description = "Environments de GitHub que pueden desplegar."
type = set(string)
default = ["production"]
validation {
condition = length(var.environments) >= 1 && alltrue([for e in var.environments : can(regex("^[A-Za-z0-9_.-]+$", e))])
error_message = "environments: al menos uno, solo letras, números, '.', '_' o '-'."
}
}
variable "deploy_roles" {
description = "Roles de proyecto que necesita el despliegue (mínimo privilegio)."
type = set(string)
validation {
condition = length(setintersection(var.deploy_roles, ["roles/owner", "roles/editor", "roles/iam.serviceAccountUser", "roles/iam.serviceAccountTokenCreator"])) == 0
error_message = "deploy_roles: nada de owner/editor ni roles que permitan actuar como otras service accounts a nivel proyecto."
}
}outputs.tf
output "workload_identity_provider" {
description = "workload_identity_provider para google-github-actions/auth (usa el número de proyecto, no el ID)."
value = google_iam_workload_identity_pool_provider.github.name
}
output "service_account" {
description = "service_account para google-github-actions/auth."
value = google_service_account.deploy.email
}
output "attribute_condition" {
description = "Condición CEL que evalúa cada token."
value = local.attribute_condition
}
output "workflow_snippet" {
description = "Fragmento mínimo del workflow."
value = <<-YAML
permissions:
id-token: write # sin esto el job no puede pedir el token OIDC
contents: read
jobs:
deploy:
environment: ${sort(tolist(var.environments))[0]}
steps:
- uses: google-github-actions/auth@v3
with:
workload_identity_provider: ${google_iam_workload_identity_pool_provider.github.name}
service_account: ${google_service_account.deploy.email}
YAML
}versions.tf
terraform {
required_version = ">= 1.7.0"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 8.6"
}
}
}
provider "google" {
project = var.project_id
region = var.region
}Microsoft Azure
Implementation
- Federated trust
- Federated identity credential (workload identity federation)One per environment: GitHub issuer + exact subject + audience api://AzureADTokenExchangeComing from AWS: there this role is IAM OIDC identity provider. There's no trust policy with wildcards: each environment, branch or tag is a credential with an exact subject, up to 20 per identity.Coming from GCP: there this role is Workload Identity Federation (pool + provider OIDC). There's no CEL condition: each environment, branch or tag is a credential with an exact subject, up to 20 per identity.
- Deployment identity
- User-assigned Managed IdentityNo secrets or certificates; the workflow only knows client-id and tenant-idComing from AWS: there this role is IAM Role.Coming from GCP: there this role is Service Account (impersonada).
- Least-privilege permissions
- Azure RBACOne role assignment on the target resource groupComing from AWS: there this role is IAM policies attached to the role.Coming from GCP: there this role is IAM.
Cost model
Scales to zero
No fixed cost
Managed identities, federated identity credentials and role assignments are free. You only pay for what the pipeline deploys.
Monthly floor with no traffic
USD 0/ mo
USD 0: no components with a fixed cost.
Fine print · 5 gotchas
The subject must match exactly, with no wildcards, and there are at most 20 credentials per identity
In Azure trust isn't a policy: each federated identity credential is an issuer + subject pair that must match the token's sub character by character. There are no wildcards, so you need one credential per environment, branch or tag, capped at 20 per identity. In AWS or GCP you'd use StringLike or a CEL condition. Watch out: a wrong subject is created without error, and the failure only shows up at login.
With Contributor the pipeline can't create role assignments
Contributor does not allow assigning roles in Azure RBAC (it excludes Microsoft.Authorization/*/Write). If your Terraform assigns roles (say, an app's identity on a Key Vault), Contributor is not enough. The quick fix is giving the pipeline Owner or User Access Administrator, which is exactly what you don't want. The right fix is Role Based Access Control Administrator with a condition limiting which roles it can assign (and excluding privileged ones).
Since 2026-07-15, new, renamed or transferred repos use a different sub format
GitHub adds the numeric IDs to the claim: repo:org@123/repo@456:environment:prod instead of repo:org/repo:environment:prod, so a recycled name doesn't inherit the trust. Older repos keep the old format unless they opt in. If you rename a repo, the sub changes and every exact credential stops matching. The module builds the subject with IDs when you pass them.
A freshly created credential can fail with AADSTS70021 for a few minutes
The credential takes time to replicate across the region. If the same pipeline creates it and uses it right away, the token exchange can fail with "No matching federated identity record found for presented assertion" even though the subject is right. Microsoft recommends waiting and retrying. If bootstrap and the first deploy run together, split them or add a retry with backoff.
Creating several credentials in parallel on the same identity returns 409
Concurrent writes of federated identity credentials under the same user-assigned identity are rejected with 409 Conflict. The azurerm provider creates them serially since 3.40, so a for_each won't break. In ARM or Bicep, though, a loop creates them in parallel: use serial mode (batchSize 1) or chain dependsOn.
Terraform
main.tf
# Despliegue desde CI sin secretos — Azure
#
# User-assigned managed identity con un federated identity credential por
# environment de GitHub (match exacto de issuer + subject) y un único role
# assignment sobre el resource group destino.
data "azurerm_client_config" "current" {}
locals {
github_issuer = "https://token.actions.githubusercontent.com"
# Único audience aceptado por Entra ID para federated identity credentials.
audience = "api://AzureADTokenExchange"
repo_path = var.github.owner_id == null ? "${var.github.owner}/${var.github.repository}" : "${var.github.owner}@${var.github.owner_id}/${var.github.repository}@${var.github.repository_id}"
# Si el job declara `environment:`, el sub es este (y no el de la rama).
subjects = { for env in var.environments : env => "repo:${local.repo_path}:environment:${env}" }
}
resource "azurerm_resource_group" "identity" {
name = "rg-${var.name}-identity"
location = var.location
tags = var.tags
}
resource "azurerm_user_assigned_identity" "deploy" {
name = "id-${var.name}"
resource_group_name = azurerm_resource_group.identity.name
location = azurerm_resource_group.identity.location
tags = var.tags
}
# azurerm crea las credenciales de una misma identidad en serie (desde 3.40);
# en ARM/Bicep hay que forzarlo (gotcha: fic-concurrent-writes-409).
resource "azurerm_federated_identity_credential" "github" {
for_each = local.subjects
name = "github-${each.key}"
user_assigned_identity_id = azurerm_user_assigned_identity.deploy.id
issuer = local.github_issuer
audience = [local.audience]
subject = each.value
}
resource "azurerm_role_assignment" "deploy" {
scope = var.target_scope_id
role_definition_name = var.role_definition_name
principal_id = azurerm_user_assigned_identity.deploy.principal_id
principal_type = "ServicePrincipal"
description = "GitHub Actions ${local.repo_path} (${join(", ", sort(tolist(var.environments)))})"
}variables.tf
variable "name" {
description = "Prefijo para nombrar los recursos (ej. \"deploy-web\")."
type = string
validation {
condition = can(regex("^[a-z0-9-]{3,24}$", var.name))
error_message = "name: 3-24 caracteres, minúsculas, números o guiones."
}
}
variable "location" {
description = "Región de Azure de la identidad (no tiene que ser la de los recursos que despliega)."
type = string
default = "eastus2"
}
variable "github" {
description = <<-EOT
Repositorio que puede desplegar. owner_id y repository_id (numéricos) activan el formato inmutable del
claim sub ("repo:owner@123/repo@456:..."), que GitHub usa por defecto en repos creados, renombrados o
transferidos desde el 15/07/2026 (gotcha: github-sub-format-changed).
EOT
type = object({
owner = string
repository = string
owner_id = optional(number)
repository_id = optional(number)
})
validation {
condition = (var.github.owner_id == null) == (var.github.repository_id == null)
error_message = "github: owner_id y repository_id van juntos (formato inmutable) o ninguno de los dos."
}
}
variable "environments" {
description = "Environments de GitHub que pueden desplegar. Uno por federated identity credential (sin comodines)."
type = set(string)
default = ["production"]
validation {
# Máximo 20 credenciales por identidad (gotcha: fic-exact-match-max-20).
condition = length(var.environments) >= 1 && length(var.environments) <= 20
error_message = "environments: entre 1 y 20 (límite de federated identity credentials por identidad)."
}
validation {
condition = alltrue([for e in var.environments : !strcontains(e, "*")])
error_message = "environments: los federated identity credentials no admiten comodines."
}
}
variable "target_scope_id" {
description = "ID del scope donde despliega el pipeline (idealmente un resource group, no la suscripción)."
type = string
validation {
condition = can(regex("^/subscriptions/[^/]+/resourceGroups/[^/]+$", var.target_scope_id))
error_message = "target_scope_id: tiene que ser un resource group (/subscriptions/<id>/resourceGroups/<nombre>)."
}
}
variable "role_definition_name" {
description = "Rol sobre el scope. Contributor no puede crear role assignments (gotcha: contributor-cannot-assign-roles)."
type = string
default = "Contributor"
validation {
condition = !contains(["Owner", "User Access Administrator"], var.role_definition_name)
error_message = "role_definition_name: Owner y User Access Administrator no; si el pipeline asigna roles, usá un role assignment con condición aparte."
}
}
variable "tags" {
description = "Tags comunes."
type = map(string)
default = {}
}outputs.tf
output "client_id" {
description = "AZURE_CLIENT_ID para azure/login (no es un secreto)."
value = azurerm_user_assigned_identity.deploy.client_id
}
output "tenant_id" {
description = "AZURE_TENANT_ID para azure/login."
value = data.azurerm_client_config.current.tenant_id
}
output "subjects" {
description = "Subjects exactos que acepta cada credencial. Compará con el sub real del token si el login falla con AADSTS70021."
value = local.subjects
}
output "workflow_snippet" {
description = "Fragmento mínimo del workflow."
value = <<-YAML
permissions:
id-token: write # sin esto el job no puede pedir el token OIDC
contents: read
jobs:
deploy:
environment: ${sort(tolist(var.environments))[0]}
steps:
- uses: azure/login@v2
with:
client-id: ${azurerm_user_assigned_identity.deploy.client_id}
tenant-id: ${data.azurerm_client_config.current.tenant_id}
subscription-id: <subscription del scope>
YAML
}versions.tf
terraform {
required_version = ">= 1.7.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 5.8"
}
}
}
provider "azurerm" {
features {}
# subscription_id se toma de ARM_SUBSCRIPTION_ID.
}