ESEN

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.

Amazon Web Services

Despliegue desde CI sin secretos (federación OIDC) — AWStoken OIDC (aud, sub)token de corta duraciónrol sobre un scopeterraform applyJob de GitHub ActionsConfianza federadaIAM OIDC identityprovider4 gotchas en este rolIdentidad de despliegueIAM Role2 gotchas en este rolPermisos mínimosIAM policies adjuntasal rolRecursos destino

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

Seguridad● verificado hace 34 días◌ sin revisor externo

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.

⚑ Reportar desactualizado
IAM● verificado hace 34 días◌ sin revisor externo

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.

⚑ Reportar desactualizado
Confiabilidad● verificado hace 34 días◌ sin revisor externo

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.

⚑ Reportar desactualizado
Operación● verificado hace 34 días◌ sin revisor externo

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.

⚑ Reportar desactualizado
Operación● verificado hace 34 días◌ sin revisor externo

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.

⚑ Reportar desactualizado

Terraform

aws ~> 6.67✓ fmt · validate · tflint · checkov · terraform testGitHub ↗
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
  }
}

Google Cloud

Despliegue desde CI sin secretos (federación OIDC) — GCPtoken OIDC (aud, sub)token de corta duraciónrol sobre un scopeterraform applyJob de GitHub ActionsConfianza federadaWorkload IdentityFederation (pool +provider OIDC)4 gotchas en este rolIdentidad de despliegueService Account(impersonada)1 gotcha en este rolPermisos mínimosIAM2 gotchas en este rolRecursos destino

Implementación

Confianza federada
Workload Identity Federation (pool + provider OIDC)attribute_condition sobre repository_owner_id, repository_id y environmentVenís de Azure: allá este rol es Federated identity credential (workload identity federation).Venís de AWS: allá este rol es IAM OIDC identity provider. La confianza es una condición CEL sobre atributos mapeados, y conviene acotarla por repository_id (inmutable) en vez del nombre.
Identidad de despliegue
Service Account (impersonada)roles/iam.workloadIdentityUser solo para el principalSet del repositorioVenís de Azure: allá este rol es User-assigned Managed Identity.Venís de AWS: allá este rol es IAM Role.
Permisos mínimos
IAMRoles de proyecto acotados; nunca owner/editorVenís de Azure: allá este rol es Azure RBAC.Venís de AWS: allá este rol es IAM policies adjuntas al rol.

Modelo de costo

Escala a cero

Sin costo fijo

Workload Identity Federation, service accounts e IAM 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

Seguridad● verificado hace 34 días◌ sin revisor externo

Sin attribute_condition, el issuer compartido de GitHub deja entrar a cualquier repo

GitHub usa un solo issuer para todos sus clientes, así que validar el issuer no alcanza. Google exige una condición que restrinja los tokens a tu organización o repositorio. En Terraform el campo figura como opcional y, sin condición, se acepta toda credencial válida del issuer. Además, todo claim que uses en la condición o en un principalSet tiene que estar mapeado en attribute_mapping.

⚑ Reportar desactualizado
Seguridad● verificado hace 34 días◌ sin revisor externo

Condicioná sobre los IDs numéricos, no sobre los nombres de org y repo

Un nombre de organización o de repositorio se puede liberar y volver a registrar: si tu condición dice repository == 'org/repo', el próximo dueño de ese nombre hereda la confianza. Google recomienda atributos inmutables, y GitHub manda repository_id y repository_owner_id en cada token. Es la misma razón por la que GitHub cambió el formato del sub en los repos creados desde el 15/07/2026.

⚑ Reportar desactualizado
IAM● verificado hace 34 días◌ sin revisor externo

El acceso directo sin service account no funciona con todos los servicios

Se puede dar roles directamente al principal federado, sin service account. Pero algunas APIs tienen limitaciones con identidades federadas y para esas hay que impersonar una service account. Este módulo impersona: funciona con todo y deja un solo lugar donde auditar permisos. Para esa ruta Google pide habilitar cuatro APIs: IAM, Resource Manager, Service Account Credentials y Security Token Service.

⚑ Reportar desactualizado
Confiabilidad● verificado hace 34 días◌ sin revisor externo

Los principals usan el número de proyecto, y los permisos tardan minutos en propagarse

Al otorgar roles a identidades externas se usa el número de proyecto, no el ID: el principalSet es principalSet://iam.googleapis.com/projects/NÚMERO_DE_PROYECTO/locations/global/workloadIdentityPools/.... Además, un cambio de IAM suele aplicarse en 2 minutos pero puede tardar 7 o más.

⚑ Reportar desactualizado
Operación● verificado hace 34 días◌ sin revisor externo

Un pool borrado bloquea su ID durante unos 30 días

Pools y providers se borran en modo soft delete y se eliminan de verdad a los ~30 días. Mientras tanto no podés crear otro con el mismo ID, así que un terraform destroy seguido de apply con el mismo pool_id falla. Las salidas son restaurarlo (undelete) e importarlo, o usar otro ID. Para entornos efímeros, generá el ID con un sufijo.

⚑ Reportar desactualizado

Terraform

google ~> 8.6✓ fmt · validate · tflint · checkov · terraform testGitHub ↗
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
}