ESEN

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.

Microsoft Azure

Secretless CI/CD deployments (OIDC federation) — AzureOIDC token (aud, sub)short-lived tokenrole on a scopeterraform applyGitHub Actions jobFederated trustFederated identitycredential (workloadidentity federation)4 gotchas in this roleDeployment identityUser-assigned ManagedIdentityLeast-privilege permissionsAzure RBAC1 gotcha in this roleTarget resources

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

IAM● verified 34 days ago◌ no external reviewer

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.

⚑ Report outdated
IAM● verified 34 days ago◌ no external reviewer

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).

⚑ Report outdated
IAM● verified 34 days ago◌ no external reviewer

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.

⚑ Report outdated
Reliability● verified 34 days ago◌ no external reviewer

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.

⚑ Report outdated
Operations● verified 34 days ago◌ no external reviewer

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.

⚑ Report outdated

Terraform

azurerm ~> 5.8✓ fmt · validate · tflint · checkov · terraform testGitHub ↗
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.
}

Amazon Web Services

Secretless CI/CD deployments (OIDC federation) — AWSOIDC token (aud, sub)short-lived tokenrole on a scopeterraform applyGitHub Actions jobFederated trustIAM OIDC identityprovider4 gotchas in this roleDeployment identityIAM Role2 gotchas in this roleLeast-privilege permissionsIAM policies attachedto the roleTarget resources

Implementation

Federated trust
IAM OIDC identity providerOne per account for token.actions.githubusercontent.com; client_id_list = sts.amazonaws.comComing from Azure: there this role is Federated identity credential (workload identity federation).Coming from GCP: there this role is Workload Identity Federation (pool + provider OIDC).
Deployment identity
IAM RoleTrust policy: AssumeRoleWithWebIdentity with exact aud and sub (StringEquals)Coming from Azure: there this role is User-assigned Managed Identity. The role bundles identity, trust (trust policy) and permissions; in Azure those are three separate objects. The trust policy allows StringLike, unlike a federated credential's exact match.Coming from GCP: there this role is Service Account (impersonada).
Least-privilege permissions
IAM policies attached to the roleScoped managed policies; never AdministratorAccessComing from Azure: there this role is Azure RBAC.Coming from GCP: there this role is IAM.

Cost model

Scales to zero

No fixed cost

IAM and STS 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

Security● verified 34 days ago◌ no external reviewer

IAM requires a sub condition, but accepts one that still leaves the door open

GitHub's issuer is the same for every organization: without scoping sub, any GitHub repository could assume your role. That's why IAM rejects creating or updating a trust policy for that provider when the sub (or job_workflow_ref) condition is missing or is just a wildcard. But "repo:*" or "repo:my-org/*" pass validation. The real boundary is yours to set: this module uses StringEquals with one sub per environment.

⚑ Report outdated
IAM● verified 34 days ago◌ no external reviewer

If the job declares an environment, the sub no longer contains the branch

GitHub includes the environment in the sub when the job references one; pull_request and the ref only appear if the job doesn't reference an environment. A job on main with environment production presents repo:org/repo:environment:production, so a trust policy expecting repo:org/repo:ref:refs/heads/main rejects AssumeRoleWithWebIdentity.

⚑ Report outdated
Reliability● verified 34 days ago◌ no external reviewer

The session lasts 1 hour by default, and role chaining caps it at 1 hour regardless

AssumeRoleWithWebIdentity grants 3600 s by default; you can request up to the role's MaxSessionDuration (12 h max), and asking for more makes the call fail. If you assume another role from it (role chaining, common in multi-account), the session is capped at 1 hour. A long apply that runs past the limit fails mid-way with a half-written state.

⚑ Report outdated
Operations● verified 34 days ago◌ no external reviewer

There's only one OIDC provider per URL per account

Creating a second provider for token.actions.githubusercontent.com in the same account fails: IAM doesn't let you register the same URL twice in an account. If each repo ships its own Terraform stack with the provider inside, the second apply breaks. Create it once (in the account stack) and reference it with the data source; the module supports that with create_oidc_provider = false.

⚑ Report outdated
Operations● verified 34 days ago◌ no external reviewer

For GitHub, IAM doesn't use the thumbprint to verify (and you don't need to set it)

IAM validates GitHub's certificate against its library of trusted root CAs, so thumbprint_list is optional and, in the Terraform provider, for IdPs like GitHub it's stored but not used for verification. Thumbprints only come into play if the IdP uses a certificate not signed by one of those CAs, or if AWS can't retrieve the TLS certificate or TLS v1.3 is required.

⚑ Report outdated

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
  }
}