Migración de GitHub Secrets (legacy) a OIDC en despliegues Terraform sobre AWS
1. Introducción
En este artículo te explico cómo migrar un repositorio que usa GitHub Secrets (legacy) con Access Keys a OIDC, tomando como ejemplo un despliegue con Terraform sobre AWS.
El objetivo es que entiendas por qué conviene migrar y cómo hacerlo con el mínimo riesgo y sin interrumpir tus pipelines.
Si buscas el tutorial completo del pipeline (con código y workflows) empieza por la guía base: Automatizando el despliegue de recursos de AWS con GitHub Actions y Terraform.
Repos de ejemplo (mismos recursos, distintas autenticaciones):
2. Por qué migrar de Access Keys (Secrets) a OIDC
Antes de tocar nada, merece la pena entender qué ganamos con el cambio.
2.1. Riesgos de las claves estáticas (método legacy)
- Exposición accidental: ramas, forks, logs, screenshots, paquetes NPM/publicaciones…
- Larga vida: si se filtran, el impacto y el radio de blast pueden ser altos.
- Rotación manual: más operativa y más riesgo humano.
- Difícil acotar el alcance: las policies tienden a sobredimensionarse.
2.2. Ventajas de OIDC (recomendado)
- Sin secretos de larga duración en GitHub → credenciales efímeras en tiempo de ejecución.
- Trust policy granular: restringe por org/repo/branch, y también por paths si lo necesitas.
- Menos operativa: adiós a rotaciones periódicas de Access Keys.
- Buenas prácticas: reduce superficie de ataque y facilita auditoría/forensics.
2.3. ¿Cuándo podría seguir usando el método legacy?
- Restricciones temporales de la organización.
- Repositorios heredados que requieren una migración progresiva.
- Entornos de laboratorio o cuentas personales (aun así, mejor OIDC).
3. Plan de migración paso a paso
Vamos a migrar de Secrets a OIDC con un plan en 6+1 pasos. Son pasos cortos y acotados, pensados para minimizar riesgos y permitir rollback si lo necesitas.
Paso 1: Inventario y preparación
- Identifica los repos que usan
AWS_ACCESS_KEY_IDyAWS_SECRET_ACCESS_KEY. - Verifica quién consume el state de Terraform (S3) y los permisos que requiere.
- (Opcional) Define Environments (p. ej.,
staging,production) y quién apruebaapply.
Paso 2: Habilitar el OIDC provider de GitHub en AWS (una vez por cuenta)
Si tu cuenta aún no lo tiene, crea el proveedor OIDC con estos datos:
- Provider URL:
https://token.actions.githubusercontent.com - Audience (client ID):
sts.amazonaws.com
Vía consola (recomendado):
- IAM → Identity providers → Add provider.
- Tipo OpenID Connect.
- Provider URL: pega
https://token.actions.githubusercontent.com. - Audience: añade
sts.amazonaws.com. - Revisa los thumbprints que la consola muestra (AWS suele rellenarlos automáticamente).
- Guarda.
Documenta el alta del proveedor (quién, cuándo y por qué) y vincúlalo a tu repositorio/proyecto.
Paso 3: Configurar IAM Role en AWS (uno por repositorio o uno por cuenta)
Lo recomendado es crear un IAM Role por repositorio, para que puedas seguir el principio del mínimo privilegio (recomendado), pero también podrías crear un solo IAM Role para todos tus despliegues de GitHub con permisos más amplios (no recomendado pero más práctico).
Vía consola (recomendado):
- IAM → Roles → Create Role
- Tipo Custom trust policy
Indicar Custom trust policy
Restringida a una rama concreta (
main) de un repo específico:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Federated": "arn:aws:iam::<YOUR_ACCOUNT_ID>:oidc-provider/token.actions.githubusercontent.com" }, "Action": "sts:AssumeRoleWithWebIdentity", "Condition": { "StringEquals": { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" }, "StringLike": { "token.actions.githubusercontent.com:sub": "repo:<YOUR_ORG_OR_USER>/<YOUR_REPO>:ref:refs/heads/main" } } } ] }
Ajusta
<YOUR_ORG_OR_USER>y<YOUR_REPO>. Si necesitas permitir más ramas, añade más patronessubo amplía el patrónStringLike.Añade los permisos de la política de mínimo privilegio
Para el ejemplo del artículo (state en S3 y AWS Budgets), el resumen de permisos sería:
- S3 (bucket del state):
ListBucket,Get/PutBucketVersioning; en objetosGet/Put/Deletesobrearn:aws:s3:::<BUCKET>/* - Budgets:
Create/Update/Delete/Describe*,Create/Update/DeleteNotification(Resource: “*“ en la mayoría de acciones)
¿Por qué estos permisos?
- S3 da servicio al state y lock de Terraform.
- Budgets es el recurso de ejemplo que crea el código del repositorio (ajústalo a tu caso).
Mantén el scope lo más acotado posible (región, tabla, bucket, prefijos). Esto reduce el impacto en caso de uso indebido y se ajusta al principio de mínimo privilegio.
- S3 (bucket del state):
- Termina de crear el rol y guárdate el ARN
Paso 4: Actualizar el workflow para usar OIDC
Fragmento clave (añade permissions y configura configure-aws-credentials con role-to-assume):
1
2
3
4
5
6
7
8
9
permissions:
id-token: write
contents: read
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.AWS_ROLE_FOR_GITHUB_DEPLOYMENTS }}
aws-region: ${{ env.AWS_REGION }}
Puedes actualizar directamente el valor de “role-to-assume” o puedes crearte una variable de GitHub y definirla en la configuración del proyecto.
En mi caso crearé una variable:
1
2
3
4
5
6
7
8
9
permissions:
id-token: write
contents: read
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.AWS_ROLE_FOR_GITHUB_DEPLOYMENTS }}
aws-region: ${{ env.AWS_REGION }}
Recuerda sustituir la variable
role-to-assumeen todas partes
Este es el Workflow completo de despliegue (con plan/apply/manual destroy, y approvals opcionales):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
name: Terraform Deploy (OIDC)
on:
push:
branches: [ main ]
paths:
- '**.tf'
- '.github/workflows/terraform-deploy.yml'
pull_request:
branches: [ main ]
paths:
- '**.tf'
- '.github/workflows/terraform-deploy.yml'
workflow_dispatch:
inputs:
action:
description: "Select action to run"
type: choice
required: true
options:
- plan-apply
- destroy
default: plan-apply
var_file:
description: "Optional .tfvars file (e.g., dev.tfvars)"
required: false
default: ""
permissions:
id-token: write # required for OIDC
contents: read # required for checkout
env:
AWS_REGION: eu-west-1
TF_IN_AUTOMATION: true
STATE_BUCKET: terraform-tfstate-playingaws-poc # <- keep in sync with backend bucket
concurrency:
group: terraform-${{ github.ref }}
cancel-in-progress: false
jobs:
plan:
name: Terraform Plan
runs-on: ubuntu-latest
# run on push/PR, or when manually triggered with action=plan-apply
if: github.event_name != 'workflow_dispatch' || github.event.inputs.action == 'plan-apply'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.AWS_ROLE_FOR_GITHUB_DEPLOYMENTS }} # or hardcode the updated value "arn:aws:iam::<YOUR_ACCOUNT_ID>:role/<ROLE_FOR_GITHUB>"
aws-region: ${{ env.AWS_REGION }}
# Optional safety net to avoid init failures if the state bucket was deleted
- name: Ensure backend bucket exists (optional)
run: |
if ! aws s3api head-bucket --bucket "$STATE_BUCKET" 2>/dev/null; then
aws s3api create-bucket --bucket "$STATE_BUCKET" \
--region "$AWS_REGION" \
--create-bucket-configuration LocationConstraint="$AWS_REGION"
aws s3api put-bucket-versioning --bucket "$STATE_BUCKET" \
--versioning-configuration Status=Enabled
fi
- name: Terraform Init
run: terraform init -input=false
- name: Terraform Format Check
run: terraform fmt -check
- name: Terraform Validate
run: terraform validate
- name: Terraform Plan
run: |
if [ -n "${{ github.event.inputs.var_file }}" ]; then
terraform plan -input=false -out=tfplan -var-file="${{ github.event.inputs.var_file }}"
else
terraform plan -input=false -out=tfplan
fi
- name: Upload plan artifact
uses: actions/upload-artifact@v4
with:
name: tfplan
path: tfplan
apply:
name: Terraform Apply (requires approval)
needs: plan
runs-on: ubuntu-latest
# do not apply on PRs; allow on push or manual action=plan-apply
if: github.event_name != 'pull_request' && (github.event_name != 'workflow_dispatch' || github.event.inputs.action == 'plan-apply')
environment:
name: production # configure required reviewers in Settings → Environments → production
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.AWS_ROLE_FOR_GITHUB_DEPLOYMENTS }}
aws-region: ${{ env.AWS_REGION }}
- name: Terraform Init
run: terraform init -input=false
- name: Download plan artifact
uses: actions/download-artifact@v4
with:
name: tfplan
path: .
- name: Terraform Apply
run: terraform apply -input=false tfplan
destroy:
name: Terraform Destroy (manual)
runs-on: ubuntu-latest
# only when manually triggered with action=destroy
if: github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'destroy'
environment:
name: production # optional: require approval for destroys as well
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.AWS_ROLE_FOR_GITHUB_DEPLOYMENTS }}
aws-region: ${{ env.AWS_REGION }}
# Optional safety net (same as in plan)
- name: Ensure backend bucket exists (optional)
run: |
if ! aws s3api head-bucket --bucket "$STATE_BUCKET" 2>/dev/null; then
aws s3api create-bucket --bucket "$STATE_BUCKET" \
--region "$AWS_REGION" \
--create-bucket-configuration LocationConstraint="$AWS_REGION"
aws s3api put-bucket-versioning --bucket "$STATE_BUCKET" \
--versioning-configuration Status=Enabled
fi
- name: Terraform Init
run: terraform init -input=false
- name: Terraform Destroy
run: |
if [ -n "${{ github.event.inputs.var_file }}" ]; then
terraform destroy -input=false -auto-approve -var-file="${{ github.event.inputs.var_file }}"
else
terraform destroy -input=false -auto-approve
fi
Paso 5: Validar que estás usando el rol OIDC
Puedes validarlo en el propio workflow (recomendado) o desde tu equipo si asumes el rol manualmente.
En el workflow (paso temporal de diagnóstico):
1
2
- name: Who am I?
run: aws sts get-caller-identity
Desde tu equipo (si asumes el rol OIDC con tus herramientas):
1
aws sts get-caller-identity
En ambos casos deberías ver el IAM Role en el campo Arn. Si ves un usuario IAM, sigues usando Access Keys (legacy).
O también puedes validar la ejecución del workflow y revisar los logs:
Dependiendo del proyecto y entorno que estés, esto puede ser otra opción. De todas formas, lo recomendado es testear que la configuración es correcta antes de ejecutarlo…
Paso 6: Retirar secretos legacy y limpiar
- Deshabilita el workflow antiguo basado en Secrets (o déjalo como
workflow_dispatchmanual, solo para emergencia). - Elimina
AWS_ACCESS_KEY_IDyAWS_SECRET_ACCESS_KEYen Settings → Secrets and variables → Actions. - Revisa CloudTrail y IAM Access Analyzer para confirmar que no quedan usos residuales.
Paso 7: Monitoreo y post-mortem ligero
- Añade alertas básicas (fallos de job, fallos de OIDC).
- Documenta incidencias de la migración (qué salió bien, lecciones aprendidas).
4. Errores comunes y cómo solucionarlos
Problemas frecuentes en migraciones reales (con mensajes típicos y arreglo rápido):
- Backend S3 inexistente
- Error:
Error: Failed to get existing workspaces: S3 bucket "<bucket>" does not exist. - Fix: crea el bucket (con versioning) y
terraform init -reconfigure. En CI, paso “Ensure backend bucket exists”. Con TF ≥ 1.10 usause_lockfile = trueen el backend y eliminadynamodb_table.
- Error:
- Recurso Budget ya existe
- Error:
DuplicateRecordException: ... the budget already exists. - Fix: añade sufijo estable con
random_idennameo elimina/importa el presupuesto existente.
- Error:
- No se cargan credenciales OIDC
- Error:
Credentials could not be loaded, please check your action inputs: Could not load credentials from any providers - Fix: comprueba
id-token: write, evento soportado (no PRs desde fork), proveedor OIDC en AWS y trust policy; verifica querole-to-assumeno esté vacío (variable/secret bien definida).
- Error:
- ARN inválido al asumir la role
- Error:
Could not assume role with OIDC: Request ARN is invalid - Fix: no hardcodes; usa en todos los jobs la misma variable/secret p.ej.
role-to-assume: $. Revisa que el ARN sea de rol, sin espacios ni comillas, y que exista en la partición correcta.
- Error:
- Bloqueo del estado en S3
- Error:
Error acquiring the state lock … PreconditionFailed (StatusCode: 412) Fix: evita carreras con
1 2 3
concurrency: group: terraform-state cancel-in-progress: true
añade
-lock-timeout=5my usa otra clave de state para PRs (key=poc/pr-<num>/terraform.tfstate). Si quedó colgado:terraform force-unlock <LOCK_ID>(con precaución).
- Error:
- Falta permiso OIDC
- Error: (no hay token)
permissions: id-token: writeausente en workflow/job Fix: añade
1 2 3
permissions: id-token: write contents: read
- Error: (no hay token)
- Trust policy no coincide
- Error: aud/sub no coinciden
aud≠sts.amazonaws.comosubno casa con el repo/branch/evento - Fix:
aud: sts.amazonaws.comysub: repo:ORG/REPO:ref:refs/heads/*(añadepull_request/refs/tags/*si aplica).
- Error: aud/sub no coinciden
- Rol sin permisos suficientes
- Error: AccessDenied en S3/DynamoDB/Budgets
- Fix: mínimo privilegio para S3 (state), Budgets y (si lo usas) DynamoDB; acota por bucket/tabla/región.
- Aprobaciones olvidadas
- Comportamiento:
applysin revisión previa - Fix: usa
environment:y configura required reviewers en GitHub → Settings → Environments.
- Comportamiento:
- Paths/triggers mal definidos
- Síntoma: el pipeline no se dispara / se dispara “de más”
- Fix: revisa
on.push.paths/paths-ignorepara incluir/ignorar lo correcto.
Una captura con algunas de las pruebas…
5. Checklist de migración (resumen)
- 1. Inventario de repos con Access Keys en Secrets.
- 2. Crear/validar OIDC provider en AWS.
- 3. Crear IAM Role (trust policy OIDC y adjuntar política de mínimo privilegio).
- 4. Actualizar workflow a OIDC (
id-token: write+role-to-assume). - 5. Validar con
aws sts get-caller-identity. - 6. Retirar secretos legacy y deshabilitar workflow antiguo.
- (Opcional) Activar Environment Protection Rules para
apply.
6. Conclusión
Migrar de Access Keys en Secrets a OIDC reduce drásticamente el riesgo, simplifica la operación y alinea tus pipelines con las mejores prácticas de seguridad.
Si necesitas un ejemplo funcionando, consulta:
- Mi artículo base que explica el proceso completo de cómo automatizar el despliegue de recursos de AWS con GitHub Actions y Terraform.
- Mi repositorio GitHub de:





