Despliegue desde CI sin secretos (federación OIDC)
Un job de GitHub Actions despliega con Terraform en la nube usando su token OIDC, sin client secrets ni access keys guardados en el repositorio.
Problema
El pipeline necesita credenciales para desplegar, y una clave guardada como secret del repo es la credencial de larga duración más expuesta que tiene una organización: no vence sola, se copia entre repos y nadie sabe quién la usó. Los requisitos son: cero secretos estáticos, confianza acotada a un repositorio y a un environment concreto (no a cualquier rama ni a cualquier repo de la organización), permisos mínimos sobre un scope y tokens que duran minutos.
La topología es la misma en todas las nubes; lo que cambia es con qué se implementa cada rol.
Confianza federada
AWS
IAM OIDC identity provider
Uno por cuenta para token.actions.githubusercontent.com; client_id_list = sts.amazonaws.com
Venís de Azure: allá este rol es Federated identity credential (workload identity federation).
Venís de GCP: allá este rol es Workload Identity Federation (pool + provider OIDC).
4 gotchas en este rol
Azure
Federated identity credential (workload identity federation)
Uno por environment: issuer de GitHub + subject exacto + audience api://AzureADTokenExchange
Venís de AWS: allá este rol es IAM OIDC identity provider. No hay trust policy con comodines: cada environment, rama o tag es una credencial con subject exacto, con un máximo de 20 por identidad.
Venís de GCP: allá este rol es Workload Identity Federation (pool + provider OIDC). No hay condición CEL: cada environment, rama o tag es una credencial con subject exacto, con un máximo de 20 por identidad.
4 gotchas en este rol
- El subject tiene que coincidir exacto, sin comodines, y hay un máximo de 20 credenciales por identidad
- Una credencial recién creada puede fallar con AADSTS70021 durante unos minutos
- Crear varias credenciales en paralelo sobre la misma identidad da 409
- Desde el 15/07/2026, los repos nuevos, renombrados o transferidos usan otro formato de sub
Identidad de despliegue
AWS
IAM Role
Trust policy: AssumeRoleWithWebIdentity con aud y sub exactos (StringEquals)
Venís de Azure: allá este rol es User-assigned Managed Identity. El rol junta identidad, confianza (trust policy) y permisos; en Azure son tres objetos separados. La trust policy admite StringLike, a diferencia del match exacto de un federated credential.
Venís de GCP: allá este rol es Service Account (impersonada).
2 gotchas en este rol
Azure
User-assigned Managed Identity
Sin secretos ni certificados; el workflow solo conoce client-id y tenant-id
Venís de AWS: allá este rol es IAM Role.
Venís de GCP: allá este rol es Service Account (impersonada).
Sin letra chica en este rol.
Permisos mínimos
AWS
IAM policies adjuntas al rol
Policies administradas acotadas; nunca AdministratorAccess
Venís de Azure: allá este rol es Azure RBAC.
Venís de GCP: allá este rol es IAM.
Sin letra chica en este rol.
Azure
Azure RBAC
Un role assignment sobre el resource group destino
Venís de AWS: allá este rol es IAM policies adjuntas al rol.
Venís de GCP: allá este rol es IAM.
1 gotcha en este rol
Amazon Web Services
Implementación
- Confianza federada
- IAM OIDC identity providerUno por cuenta para token.actions.githubusercontent.com; client_id_list = sts.amazonaws.comVenís de Azure: allá este rol es Federated identity credential (workload identity federation).Venís de GCP: allá este rol es Workload Identity Federation (pool + provider OIDC).
- Identidad de despliegue
- IAM RoleTrust policy: AssumeRoleWithWebIdentity con aud y sub exactos (StringEquals)Venís de Azure: allá este rol es User-assigned Managed Identity. El rol junta identidad, confianza (trust policy) y permisos; en Azure son tres objetos separados. La trust policy admite StringLike, a diferencia del match exacto de un federated credential.Venís de GCP: allá este rol es Service Account (impersonada).
- Permisos mínimos
- IAM policies adjuntas al rolPolicies administradas acotadas; nunca AdministratorAccessVenís de Azure: allá este rol es Azure RBAC.Venís de GCP: allá este rol es IAM.
Modelo de costo
Escala a cero
Sin costo fijo
IAM y STS no tienen costo. Pagás solo los recursos que despliega el pipeline.
Piso mensual sin tráfico
USD 0/ mes
USD 0: no hay componentes con costo fijo.
Letra chica · 5 gotchas
IAM exige una condición sobre sub, pero acepta una que igual abre la puerta
El issuer de GitHub es el mismo para todas las organizaciones: sin acotar el sub, cualquier repositorio de GitHub podría asumir tu rol. Por eso IAM rechaza crear o actualizar una trust policy hacia ese provider si falta la condición sobre sub (o job_workflow_ref) o si es solo un comodín. Pero "repo:*" o "repo:mi-org/*" pasan la validación. El límite real lo ponés vos: este módulo usa StringEquals con un sub por environment.
Si el job declara un environment, el sub deja de tener la rama
GitHub incluye el environment en el sub cuando el job lo referencia; pull_request y la ref solo entran si el job no referencia un environment. Un job en main con environment production presenta repo:org/repo:environment:production, así que una trust policy que espera repo:org/repo:ref:refs/heads/main rechaza el AssumeRoleWithWebIdentity.
La sesión dura 1 hora por default, y encadenar roles la limita a 1 hora sí o sí
AssumeRoleWithWebIdentity da 3600 s por default; podés pedir hasta el MaxSessionDuration del rol (máximo 12 h), y pedir más que eso hace fallar la llamada. Si desde ese rol asumís otro (role chaining, típico en multi-cuenta), la sesión queda en 1 hora como máximo. Un apply largo que se pasa del límite falla a mitad de camino con el state a medio escribir.
Hay un solo OIDC provider por URL y por cuenta
Crear un segundo provider para token.actions.githubusercontent.com en la misma cuenta falla: IAM no permite registrar la misma URL dos veces en una cuenta. Si cada repo trae su propio stack de Terraform con el provider adentro, el segundo apply rompe. Crealo una vez (en el stack de la cuenta) y referencialo con el data source; el módulo lo permite con create_oidc_provider = false.
Para GitHub, IAM no usa el thumbprint para verificar (y no hace falta cargarlo)
IAM valida el certificado de GitHub contra su librería de CAs raíz confiables, así que thumbprint_list es opcional y, en el provider de Terraform, para IdPs como GitHub se guarda pero no se usa para verificar. Los thumbprints solo entran en juego si el IdP usa un certificado que no firma una de esas CAs, o si AWS no puede obtener el certificado TLS o se requiere TLS v1.3.
Terraform
main.tf
# Despliegue desde CI sin secretos — AWS
#
# OIDC provider de GitHub (uno por cuenta) y un rol IAM cuya trust policy
# exige el audience de STS y un sub exacto por environment.
locals {
github_host = "token.actions.githubusercontent.com"
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 = sort([for env in var.environments : "repo:${local.repo_path}:environment:${env}"])
provider_arn = var.create_oidc_provider ? aws_iam_openid_connect_provider.github[0].arn : data.aws_iam_openid_connect_provider.github[0].arn
trust_conditions = {
StringEquals = {
"${local.github_host}:aud" = "sts.amazonaws.com"
"${local.github_host}:sub" = local.subjects
}
}
}
# Sin thumbprint_list: para GitHub, IAM valida el certificado contra su librería
# de CAs raíz y no usa el thumbprint (gotcha: thumbprint-not-used).
resource "aws_iam_openid_connect_provider" "github" {
count = var.create_oidc_provider ? 1 : 0
url = "https://${local.github_host}"
client_id_list = ["sts.amazonaws.com"]
}
data "aws_iam_openid_connect_provider" "github" {
count = var.create_oidc_provider ? 0 : 1
url = "https://${local.github_host}"
}
resource "aws_iam_role" "deploy" {
name = var.name
description = "GitHub Actions ${local.repo_path} (${join(", ", local.subjects)})"
max_session_duration = var.max_session_duration
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRoleWithWebIdentity"
Principal = { Federated = local.provider_arn }
Condition = local.trust_conditions
}]
})
}
resource "aws_iam_role_policy_attachment" "deploy" {
for_each = var.policy_arns
role = aws_iam_role.deploy.name
policy_arn = each.value
}variables.tf
variable "name" {
description = "Nombre del rol de despliegue (ej. \"deploy-web\")."
type = string
validation {
condition = can(regex("^[a-zA-Z0-9+=,.@_-]{3,64}$", var.name))
error_message = "name: 3-64 caracteres válidos para un rol IAM."
}
}
variable "region" {
description = "Región del provider (IAM es global)."
type = string
default = "us-east-1"
}
variable "create_oidc_provider" {
description = "Crear el OIDC provider de GitHub. Hay uno solo por URL y por cuenta: si ya existe, false y se referencia (gotcha: one-oidc-provider-per-account)."
type = bool
default = true
}
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.
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 asumir el rol (match exacto con StringEquals)."
type = set(string)
default = ["production"]
validation {
condition = length(var.environments) >= 1 && alltrue([for e in var.environments : !can(regex("[*?]", e))])
error_message = "environments: al menos uno y sin comodines (el módulo usa StringEquals a propósito)."
}
}
variable "policy_arns" {
description = "Policies administradas que necesita el despliegue (mínimo privilegio)."
type = set(string)
validation {
condition = !contains(var.policy_arns, "arn:aws:iam::aws:policy/AdministratorAccess")
error_message = "policy_arns: AdministratorAccess no. Acotá los permisos a lo que despliega el pipeline."
}
}
variable "max_session_duration" {
description = "Duración máxima de la sesión en segundos (1 h a 12 h). Si el apply dura más que la sesión, falla a mitad de camino."
type = number
default = 3600
validation {
condition = var.max_session_duration >= 3600 && var.max_session_duration <= 43200
error_message = "max_session_duration: entre 3600 y 43200."
}
}
variable "tags" {
description = "Tags comunes."
type = map(string)
default = {}
}outputs.tf
output "role_arn" {
description = "role-to-assume para aws-actions/configure-aws-credentials (no es un secreto)."
value = aws_iam_role.deploy.arn
}
output "subjects" {
description = "Valores exactos de sub que acepta la trust policy."
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: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${aws_iam_role.deploy.arn}
aws-region: ${var.region}
YAML
}versions.tf
terraform {
required_version = ">= 1.7.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.67"
}
}
}
provider "aws" {
region = var.region
default_tags {
tags = var.tags
}
}Microsoft Azure
Implementación
- Confianza federada
- Federated identity credential (workload identity federation)Uno por environment: issuer de GitHub + subject exacto + audience api://AzureADTokenExchangeVenís de AWS: allá este rol es IAM OIDC identity provider. No hay trust policy con comodines: cada environment, rama o tag es una credencial con subject exacto, con un máximo de 20 por identidad.Venís de GCP: allá este rol es Workload Identity Federation (pool + provider OIDC). No hay condición CEL: cada environment, rama o tag es una credencial con subject exacto, con un máximo de 20 por identidad.
- Identidad de despliegue
- User-assigned Managed IdentitySin secretos ni certificados; el workflow solo conoce client-id y tenant-idVenís de AWS: allá este rol es IAM Role.Venís de GCP: allá este rol es Service Account (impersonada).
- Permisos mínimos
- Azure RBACUn role assignment sobre el resource group destinoVenís de AWS: allá este rol es IAM policies adjuntas al rol.Venís de GCP: allá este rol es IAM.
Modelo de costo
Escala a cero
Sin costo fijo
Managed identities, federated identity credentials y role assignments no tienen costo. Pagás solo los recursos que despliega el pipeline.
Piso mensual sin tráfico
USD 0/ mes
USD 0: no hay componentes con costo fijo.
Letra chica · 5 gotchas
El subject tiene que coincidir exacto, sin comodines, y hay un máximo de 20 credenciales por identidad
En Azure la confianza no es una policy: cada federated identity credential es un par issuer + subject que tiene que coincidir carácter por carácter con el sub del token. No hay comodines, así que necesitás una credencial por environment, rama o tag, con un tope de 20 por identidad. En AWS o GCP lo resolvés con StringLike o una condición CEL. Ojo: si cargás mal el subject, la credencial se crea sin error y la falla recién aparece en el login.
Con Contributor el pipeline no puede crear role assignments
Contributor no permite asignar roles en Azure RBAC (excluye Microsoft.Authorization/*/Write). Si tu Terraform asigna roles (por ejemplo, la identidad de una app sobre un Key Vault), Contributor no alcanza. La salida rápida es darle Owner o User Access Administrator al pipeline, y es justo lo que no querés. La salida correcta es Role Based Access Control Administrator con una condición que limite qué roles puede asignar (y que excluya los roles privilegiados).
Desde el 15/07/2026, los repos nuevos, renombrados o transferidos usan otro formato de sub
GitHub agrega los IDs numéricos al claim: repo:org@123/repo@456:environment:prod en lugar de repo:org/repo:environment:prod. Así un nombre reciclado no hereda la confianza. Los repos anteriores siguen con el formato viejo salvo que lo activen. Si renombrás un repo, el sub cambia y todas las credenciales exactas dejan de matchear. El módulo arma el subject con IDs cuando se los pasás.
Una credencial recién creada puede fallar con AADSTS70021 durante unos minutos
La credencial tarda en replicarse en la región. Si el mismo pipeline la crea y enseguida la usa, el token exchange puede fallar con "No matching federated identity record found for presented assertion" aunque el subject esté bien. Microsoft recomienda esperar y reintentar. Si el bootstrap y el primer deploy van en la misma corrida, separalos o agregá un reintento con espera.
Crear varias credenciales en paralelo sobre la misma identidad da 409
Las escrituras concurrentes de federated identity credentials bajo una misma user-assigned identity se rechazan con 409 Conflict. El provider azurerm las crea en serie desde la 3.40, así que un for_each no rompe. En ARM o Bicep, en cambio, un loop las crea en paralelo: usá el modo serial (batchSize 1) o encadená 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.
}