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.

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

Google Cloud

Secretless CI/CD deployments (OIDC federation) — GCPOIDC token (aud, sub)short-lived tokenrole on a scopeterraform applyGitHub Actions jobFederated trustWorkload IdentityFederation (pool +provider OIDC)4 gotchas in this roleDeployment identityService Account(impersonada)1 gotcha in this roleLeast-privilege permissionsIAM2 gotchas in this roleTarget resources

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

Security● verified 34 days ago◌ no external reviewer

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.

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

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.

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

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.

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

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.

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

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.

⚑ Report outdated

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
}