AWS_ACCESS_KEY_ID en variables de CI fue el estándar de 2018. En 2026 es un antipatrón. La identidad federada con OIDC entrega al pipeline credenciales STS de corta duración, acotadas por el claim sub del token, y CloudTrail deja de registrar un usuario máquina anónimo y empieza a registrar el workflow concreto con su rama y su repositorio. El mismo patrón sirve para GitHub Actions, GitLab CI y un Atlantis self-hosted.
Qué falla en una clave de larga duración
Un aws_iam_access_key vive meses. Reside en los secrets del repositorio o en variables de CI, y eso genera tres problemas que ninguna «política de contraseñas mejorada» resuelve:
- Rotación manual. Reemitir la clave significa abrir un PR en la SaaS, poner un valor nuevo en cada secret de cada repositorio y confiar en que nadie la haya guardado en
~/.aws/credentialsde su portátil. - Radio de impacto al filtrarse. Una clave con permiso para
terraform applyfunciona igual desde la shell del atacante que desde el CI. El borde de red es0.0.0.0/0. - CloudTrail sin atribución. Aparece
eventSource=ec2.amazonaws.com,userIdentity.userName=ci-deployer-prod, y no hay forma de relacionarlo con qué PR o qué persona disparó el botón.
OIDC como reemplazo: tres actores
El flujo federado cambia «pasar la clave» por «cambiar un token por una sesión corta». Hay tres actores:
- El proveedor de CI emite un JWT firmado (el
id_token) con claims de repositorio, rama, environment y project id. - Un IAM OIDC Identity Provider en AWS — uno por proveedor, con ARN del tipo
arn:aws:iam::<acc>:oidc-provider/<issuer-host>. La audience siempre essts.amazonaws.com. - Un rol IAM con trust policy — el principal
Federatedapunta al proveedor OIDC y laConditionfiltra las peticiones por el claimsub.
Cuando un job arranca, el CI coloca el JWT en una variable temporal y llama a sts:AssumeRoleWithWebIdentity. AWS verifica la firma, compara aud y sub con la trust policy y devuelve AccessKeyId/SecretAccessKey/SessionToken válidos durante una hora o menos. CloudTrail ya no muestra iam-user/ci-prod, sino arn:aws:sts::…:assumed-role/<role>/<run-id> con el claim sub dentro de webIdFederationData.
Setup mínimo en tres proveedores
GitHub Actions — issuer token.actions.githubusercontent.com, formato del sub repo:org/repo:ref:refs/heads/main (o :environment:prod, o :pull_request):
permissions:
id-token: write # obligatorio
contents: read
jobs:
deploy:
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::<acct>:role/gha-deploy
aws-region: eu-central-1
- run: terraform apply -auto-approve
GitLab CI (SaaS) — issuer https://gitlab.com, formato del sub project_path:group/project:ref_type:branch:ref:main. El JWT llega como id_tokens, y el intercambio es un paso explícito de aws sts assume-role-with-web-identity.
Atlantis self-hosted — el issuer es el propio Atlantis o el IDP que tenga detrás. Acotar el scope por rol IAM por proyecto, no un «atlantis-runner» compartido.
La trust policy es idéntica salvo issuer y sub-pattern:
resource "aws_iam_role" "ci_deploy" {
assume_role_policy = jsonencode({
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRoleWithWebIdentity"
Principal = { Federated = "arn:aws:iam::${var.acc}:oidc-provider/${var.issuer}" }
Condition = {
StringEquals = { "${var.issuer}:aud" = "sts.amazonaws.com" }
StringLike = { "${var.issuer}:sub" = var.sub_pattern }
}
}]
})
}
Lo que no se puede hacer, incluso cuando «funciona»
Tres antipatrones aparecen una y otra vez en repositorios públicos y anulan la ganancia de OIDC:
AdministratorAccessen el rol OIDC «para la demo». Un rol federado tiene que ser least-privilege. Si no, al comprometerse un script de build (dependencia npm, hop de supply-chain), el atacante consigue exactamente lo que daba eliam-useranterior, solo que con mejor atribución. Ganancia cero.- Un único job que ejecuta
npm buildyterraform apply. El build corre en un runner por defecto, puede tirar de dependencias arbitrarias, y ese mismo proceso recibe luego un token temporal con permisos de producción. La forma correcta: un job de build sin asumir ningún rol que empuja el artefacto a un registry, y un job de apply separado que llama aconfigure-aws-credentials. Una dependencia comprometida solo llega a los permisos de pull. AWS_ACCESS_KEY_IDen el entorno del runner sigue ahí «por compatibilidad». Las variables de entorno prevalecen sobreAWS_PROFILEen la CLI de aws; unAWS_ACCESS_KEY_ID=…olvidado en variables a nivel de grupo silencia la sesión OIDC sin avisar. Antes deterraformen la shell del CI, siempreunset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN.
Trampa aparte para Terraform: module { source = "s3::…" } no respeta AWS_PROFILE, así que los módulos se descargan con las credenciales por defecto. Dos arreglos: ejecutar aws configure export-credentials --format env antes de terraform init, o guardar los módulos en Git/registry en lugar de en S3.
Lo que dejarán de hacer
Tras la migración desaparece la rutina: rotar claves, revisar quién y cuándo metió AWS_* en secrets, gestionar la salida de personas con claves en sus portátiles. La cuenta de secretos permanentes en CI cae a cero. La superficie de ataque del CI/CD se reduce a falsificar la firma JWT de GitHub o GitLab — algo que, en cualquier modelo de amenaza realista, equivale a comprometer al propio proveedor.
Esa es la rara migración de seguridad que elimina procedimientos en lugar de añadirlos.