Nota

OIDC → AWS STS: CI/CD sin claves de larga duración

La identidad federada sustituye AWS_ACCESS_KEY_ID en CI/CD: un patrón para GitHub, GitLab y Atlantis — sin rotación y con atribución real en CloudTrail.

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:

  1. 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/credentials de su portátil.
  2. Radio de impacto al filtrarse. Una clave con permiso para terraform apply funciona igual desde la shell del atacante que desde el CI. El borde de red es 0.0.0.0/0.
  3. 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 es sts.amazonaws.com.
  • Un rol IAM con trust policy — el principal Federated apunta al proveedor OIDC y la Condition filtra las peticiones por el claim sub.

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:

  • AdministratorAccess en 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 el iam-user anterior, solo que con mejor atribución. Ganancia cero.
  • Un único job que ejecuta npm build y terraform 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 a configure-aws-credentials. Una dependencia comprometida solo llega a los permisos de pull.
  • AWS_ACCESS_KEY_ID en el entorno del runner sigue ahí «por compatibilidad». Las variables de entorno prevalecen sobre AWS_PROFILE en la CLI de aws; un AWS_ACCESS_KEY_ID=… olvidado en variables a nivel de grupo silencia la sesión OIDC sin avisar. Antes de terraform en la shell del CI, siempre unset 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.