Despliegue a Azure desde GitHub Actions sin secretos
GitHub Actions, OIDC, Microsoft Entra ID, Azure App Service, .NET 10
Cambié el client secret de un pipeline de GitHub Actions por workload identity federation y documenté cada error del camino.
El subject tiene que coincidir exactamente con lo que emite GitHub. Los repositorios creados después de mediados de 2026 reciben subjects inmutables con los IDs del owner y del repo, pero también con el nombre. Renombrar el repo o añadir environment: al job rompe la federación. Copia el subject del log de azure/login, no de un tutorial.
Vas a desplegar una API en .NET 10 en Azure App Service desde GitHub Actions sin guardar ningún secreto. Antes, el workflow se autentica con un client secret que caduca y hay que rotar. Después, GitHub emite un token de cinco minutos y Microsoft Entra ID lo cambia por un acceso a Azure.
Todo lo que sigue lo hice de punta a punta en una suscripción real. Los comandos, el YAML y los mensajes de error son los que salieron. El repositorio es público: mbarretot/github-actions-azure-oidc.
Lo que necesitas
- Una suscripción de Azure y permisos para crear un grupo de recursos, una app registration y una asignación de rol.
- Azure CLI con sesión iniciada (
az login). - GitHub CLI (
gh) con sesión iniciada. - Un repositorio propio: un fork o una copia del repositorio del laboratorio.
- .NET SDK 10 si quieres compilar en local. El workflow usa la versión fijada en
global.json.
Arquitectura
Son tres piezas. Un workflow de GitHub Actions publica la API. Una app registration en Entra ID representa a ese workflow. Una Web App en un plan F1 (gratuito) recibe el paquete.
La diferencia entre el antes y el después está en cómo el workflow demuestra quién es. Con un client secret, presenta una contraseña guardada en GitHub. Con workload identity federation, presenta un token OIDC firmado por GitHub, y Entra ID lo acepta solo si coincide con una credencial federada.
El token dice quién lo pide; la credencial federada, a quién crees.
1. Partir de la plantilla
La API sale de mi plantilla dotnet-clean-architecture. Solo se despliega un proyecto: src/CleanArchitecture.Presentation, una ASP.NET Core Web API en net10.0.
Antes de tocar Azure, ajusté tres cosas:
- Compila solo el proyecto que despliegas.
dotnet build -c Releasesobre la solución completa falló conNU1902en el AppHost de Aspire: una vulnerabilidad moderada en un paquete transitivo, convertida en error porTreatWarningsAsErrors=true. Publicar soloPresentationlo evita, y es lo correcto para un despliegue. - Desactiva las migraciones al arrancar. La app aplica migraciones de PostgreSQL al iniciar. Sin base de datos, se cae. Añadí un flag de configuración,
Database:ApplyMigrationsOnStartup, que valetruepor defecto. En la Web App se pone afalse. - Comprueba con
/alive. En local,/aliveresponde 200,/healthresponde 503 porque incluye la base de datos y/responde 401 porque la app exige autenticación por defecto. La prueba de humo usa/alive.
2. Crear la infraestructura
Define las variables y crea el grupo de recursos, el plan y la Web App:
SUB="<subscription-id>"
RG="rg-github-oidc"
LOCATION="centralus"
PLAN="asp-github-oidc"
APP="github-oidc-$RANDOM" # el nombre debe ser único en *.azurewebsites.net
REPO="<owner>/<repo>"
az account set --subscription "$SUB"
az group create -n "$RG" -l "$LOCATION"
az appservice plan create -n "$PLAN" -g "$RG" -l "$LOCATION" --is-linux --sku F1
az webapp create -n "$APP" -g "$RG" -p "$PLAN" --runtime "DOTNETCORE:10.0"
# La app aplica migraciones de PostgreSQL al arrancar. Sin base de datos, se desactivan:
az webapp config appsettings set -n "$APP" -g "$RG" \
--settings Database__ApplyMigrationsOnStartup=falseEl plan F1 no está disponible en todas las regiones. En mi primera ejecución, eastus y eastus2 respondieron Operation cannot be completed without additional quota y westeurope no aceptaba clientes nuevos. centralus funcionó. El grupo de recursos y el plan pueden estar en regiones distintas: la ubicación del grupo es solo metadatos.
Después, cambia AZURE_WEBAPP_NAME en el workflow por el valor de $APP.
La Web App en un plan F1, en Central US, con .NET 10.
3. El “antes”: desplegar con client secret
Este es el punto de partida que vas a eliminar. Crea la app registration, su service principal y el rol:
APP_ID=$(az ad app create --display-name github-actions-azure-oidc \
--sign-in-audience AzureADMyOrg --query appId -o tsv)
SP_ID=$(az ad sp create --id "$APP_ID" --query id -o tsv)
WEBAPP_ID=$(az webapp show -n "$APP" -g "$RG" --query id -o tsv)
az role assignment create \
--assignee-object-id "$SP_ID" --assignee-principal-type ServicePrincipal \
--role "Website Contributor" --scope "$WEBAPP_ID"Website Contributor con alcance en la Web App basta para que azure/webapps-deploy publique el paquete. El pipeline no puede crear ni borrar nada más. Tampoco hace falta un rol a nivel de suscripción: con el rol solo sobre la Web App, azure/login fijó la suscripción sin problema.
El service principal solo tiene Website Contributor sobre la Web App.
Ahora el secreto. Lo paso directamente de az a gh, sin mostrarlo ni escribirlo en disco:
az ad app credential reset --id "$APP_ID" --display-name gh-actions-antes --years 1 \
--query password -o tsv \
| gh secret set AZURE_CLIENT_SECRET -R "$REPO"
gh secret set AZURE_CLIENT_ID -R "$REPO" -b "$APP_ID"
gh secret set AZURE_TENANT_ID -R "$REPO" -b "$(az account show --query tenantId -o tsv)"
gh secret set AZURE_SUBSCRIPTION_ID -R "$REPO" -b "$SUB"Ojo con az ad app credential reset: reemplaza las credenciales existentes salvo que pases --append. Es una trampa clásica al rotar secretos.
La app registration del antes: un client secret.
El client secret que vas a eliminar.
En GitHub: tres identificadores y un secreto de verdad.
El workflow del antes se autentica con creds. Lo verás completo en el paso 5. Funcionó a la primera: unos 90 segundos de principio a fin. El propio azure/login imprime un aviso al usarlo:
Note: Azure/login action also supports OIDC login mechanism.
Attempting Azure CLI login by using service principal with secret...
Azure CLI login succeeds by using service principal with secret.4. Registrar la aplicación y el federated credential
La app registration ya existe desde el paso 3. Ahora le dices en qué tokens de GitHub debe confiar. Eso se define con tres valores: el emisor, la audiencia y el subject.
El subject es el que falla. No lo escribas a mano: consulta el prefijo real de tu repositorio.
gh api "repos/$REPO/actions/oidc/customization/sub"
# {"use_default":true,"use_immutable_subject":true,
# "sub_claim_prefix":"repo:<owner>@<owner_id>/<repo>@<repo_id>"}Los repositorios creados después del 15 de julio de 2026 usan por defecto subjects inmutables, con los IDs numéricos del propietario y del repositorio. El mío, por ejemplo, es repo:mbarretot@102126994/github-actions-azure-oidc@1436045848.
Con ese prefijo, crea la credencial para la rama main:
PREFIX=$(gh api "repos/$REPO/actions/oidc/customization/sub" --jq .sub_claim_prefix)
cat > fic-main.json <<EOF
{
"name": "gh-main-branch",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "$PREFIX:ref:refs/heads/main",
"audiences": ["api://AzureADTokenExchange"]
}
EOF
az ad app federated-credential create --id "$APP_ID" --parameters fic-main.jsonEl workflow final declara environment: production. Con un entorno, el subject deja de contener la rama y pasa a terminar en :environment:production. Por eso hace falta una segunda credencial:
cat > fic-env.json <<EOF
{
"name": "gh-env-production",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "$PREFIX:environment:production",
"audiences": ["api://AzureADTokenExchange"]
}
EOF
az ad app federated-credential create --id "$APP_ID" --parameters fic-env.jsonEl portal ya pide los IDs y avisa de usar el formato inmutable.
Dos credenciales: una para la rama main y otra para el entorno production.
Referenciar environment: production en el workflow crea el entorno en GitHub de forma automática, pero sin reglas de protección. Si quieres aprobaciones manuales, configúralas en Settings → Environments.
5. Cambiar el workflow a OIDC
El cambio son dos piezas: el bloque permissions y el login con tres identificadores en lugar de creds.
name: Deploy to Azure App Service
on:
push:
branches: [main]
paths-ignore: ['**.md', 'docs/**']
workflow_dispatch:
env:
AZURE_WEBAPP_NAME: <nombre-de-tu-web-app>
PROJECT_PATH: src/CleanArchitecture.Presentation/CleanArchitecture.Presentation.csproj
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
with:
global-json-file: global.json
- name: Publish
run: dotnet publish "$PROJECT_PATH" -c Release -o ./publish
- name: Azure login (client secret)
uses: azure/login@v3
with:
creds: >-
{"clientId":"${{ secrets.AZURE_CLIENT_ID }}",
"clientSecret":"${{ secrets.AZURE_CLIENT_SECRET }}",
"subscriptionId":"${{ secrets.AZURE_SUBSCRIPTION_ID }}",
"tenantId":"${{ secrets.AZURE_TENANT_ID }}"}
- name: Deploy
uses: azure/webapps-deploy@v3
with:
app-name: ${{ env.AZURE_WEBAPP_NAME }}
package: ./publishname: Deploy to Azure App Service
on:
push:
branches: [main]
paths-ignore: ['**.md', 'docs/**']
workflow_dispatch:
env:
AZURE_WEBAPP_NAME: <nombre-de-tu-web-app>
PROJECT_PATH: src/CleanArchitecture.Presentation/CleanArchitecture.Presentation.csproj
permissions:
id-token: write # lets the job request a GitHub OIDC token
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
with:
global-json-file: global.json
- name: Publish
run: dotnet publish "$PROJECT_PATH" -c Release -o ./publish
- name: Azure login (OIDC)
uses: azure/login@v3
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Deploy
uses: azure/webapps-deploy@v3
with:
app-name: ${{ env.AZURE_WEBAPP_NAME }}
package: ./publishid-token: write permite al job pedir el token OIDC. Al declarar permissions, el resto de permisos del GITHUB_TOKEN pasan a none; por eso hace falta contents: read para el checkout.
El cambio completo cabe en un diff pequeño.
El workflow final en el repositorio.
6. Borrar el secreto
Con el login OIDC funcionando, el secreto sobra. Bórralo en los dos lados:
gh secret delete AZURE_CLIENT_SECRET -R "$REPO"
az ad app credential list --id "$APP_ID"
az ad app credential delete --id "$APP_ID" --key-id <key-id>
az ad app credential list --id "$APP_ID" # []En GitHub quedan tres valores: AZURE_CLIENT_ID, AZURE_TENANT_ID y AZURE_SUBSCRIPTION_ID. Ninguno permite autenticarse por sí solo. Los guardo como secretos para que GitHub los enmascare (***) en los logs de un repositorio público. También podrían ser variables (vars.*).
Cero client secrets en Entra ID.
En GitHub solo quedan identificadores.
7. Verificar
Lanza el workflow y comprueba la app:
gh workflow run deploy.yml -R "$REPO" --ref main
curl -i "https://$APP.azurewebsites.net/alive" # 200 HealthyLa primera petición puede tardar: el plan F1 no tiene Always On y la app arranca en frío. En mi caso, el primer curl tardó 83,9 segundos. Después, /alive respondió en menos de un segundo.
En el log del paso de login, azure/login@v3 imprime el token que presenta. Es la línea más útil de todo el laboratorio:
Federated token details:
issuer - https://token.actions.githubusercontent.com
subject claim - repo:<owner>@<owner_id>/<repo>@<repo_id>:environment:production
audience - api://AzureADTokenExchange
Attempting Azure CLI login by using OIDC...La línea subject claim es lo que debe ir en la credencial federada.
El login OIDC tardó 5 segundos; el login con secreto, 10. El token dura 5 minutos: no hay nada que rotar.
Los errores que me encontré
Fallé varias veces antes del despliegue limpio. Algunos fallos fueron accidentales y otros los provoqué a propósito para documentarlos.
Cada ejecución en rojo es uno de los errores de esta sección.
Falta id-token: write
Cambié el login a OIDC y olvidé el bloque permissions:
##[error]Failed to fetch federated token from GitHub. Please make sure to give write permissions to id-token in the workflow.
##[error]Login failed with Error: Error message: Unable to get ACTIONS_ID_TOKEN_REQUEST_URL env variable. Double check if the 'auth-type' is correct. Refer to https://github.com/Azure/login#readme for more information.Sin ese permiso, el runner no recibe la URL para pedir el token. Falla dentro de GitHub y nunca llega a Entra ID. Solución: id-token: write y contents: read.
El error de permisos falla rápido y dentro de GitHub.
AADSTS700213 en la propia rama main
Creé la primera credencial con el formato antiguo y el job falló en main:
##[error]AADSTS700213: No matching federated identity record found for presented assertion subject 'repo:mbarretot@102126994/wif-lab-clean-architecture@1436045848:ref:refs/heads/main'. Check your federated identity credential Subject, Audience and Issuer against the presented assertion.Actualicé la credencial con el subject inmutable y el reintento funcionó.
AADSTS700213 desde otra rama
Lancé el workflow desde feature/try-deploy. Falló con el mismo código y el subject …:ref:refs/heads/feature/try-deploy. No es un error: es la propiedad que buscas. Una rama de trabajo no puede desplegar en producción.
AADSTS700213 al añadir environment:
Añadí environment: production al job y un despliegue que funcionaba dejó de hacerlo, aunque seguía en main:
##[error]AADSTS700213: No matching federated identity record found for presented assertion subject 'repo:mbarretot@102126994/github-actions-azure-oidc@1436045848:environment:production'.Con un entorno, el subject termina en :environment:production en lugar de :ref:refs/heads/main. Entra ID solo mira el subject. La solución es la segunda credencial del paso 4.
Una línea de YAML cambió la identidad del job.
AADSTS700213 después de renombrar el repositorio
El laboratorio empezó como wif-lab-clean-architecture y luego lo renombré. Esperaba que el subject inmutable sobreviviera al cambio porque lleva IDs. No fue así: lleva los IDs además del nombre, y el nombre cambia.
##[error]AADSTS700213: No matching federated identity record found for presented assertion subject 'repo:mbarretot@102126994/github-actions-azure-oidc@1436045848:ref:refs/heads/main'.Los IDs evitan que otro repositorio reutilice el nombre; no protegen frente a un renombrado. Al actualizar la credencial con el nombre nuevo, el job pasó.
Renombrar el repositorio rompe la credencial federada.
AADSTS700212: audiencia incorrecta
Forcé una audiencia distinta en azure/login:
##[error]AADSTS700212: No matching federated identity record found for presented assertion audience 'api://wif-lab-wrong-audience'.Usa la audiencia por defecto, api://AzureADTokenExchange. Para recordarlo: 700213 es el subject; 700212, la audiencia.
AADSTS70025: la credencial aún no existe para Entra ID
Un minuto después de crear la primera credencial en una app nueva:
##[error]AADSTS70025: The client '***'(github-actions-azure-oidc) has no configured federated identity credentials.No era un error de configuración. Era retraso de replicación. Además, los cambios en credenciales federadas no se propagan a la vez: un job falló con AADSTS700213 un minuto después de que otro con el mismo subject hubiera funcionado, y el reintento pasó. Un único job en verde no prueba que el cambio ya esté en todas partes.
Lo que haría distinto
- Consultar el subject antes de crear la credencial.
gh api repos/<owner>/<repo>/actions/oidc/customization/subme habría ahorrado el primerAADSTS700213. - Crear la credencial del entorno al mismo tiempo que la de la rama. Si el workflow va a usar
environment:, la credencial de la rama no basta. - Renombrar el repositorio antes de federarlo. O actualizar la credencial justo después.
- Esperar un minuto tras cada cambio en Entra ID antes de concluir que la configuración está mal.
- Configurar reglas de protección en el entorno. GitHub lo crea vacío; la aprobación solo existe si la defines.
Limpieza y coste
El plan F1 es gratuito. No tiene Always On, así que la app arranca en frío tras un rato sin tráfico.
Cuando termines el laboratorio, borra lo que creaste:
az role assignment delete --assignee "$SP_ID" --scope "$WEBAPP_ID"
az group delete -n "$RG" --yes
az ad app delete --id "$APP_ID"az ad app delete solo hace un borrado lógico. La app queda 30 días en Deleted applications de Entra ID, salvo que la elimines de forma permanente.
Fuentes
- OpenID Connect en GitHub Actions, GitHub Docs.
- Configurar OpenID Connect en Azure, GitHub Docs.
- Immutable subject claims for GitHub Actions OIDC tokens, GitHub Changelog.
- Migrate GitHub Actions federated credentials to immutable subjects, Microsoft.
- Workload identity federation, Microsoft Learn.
- Configurar una app para confiar en un proveedor de identidad externo, Microsoft Learn.
- Desplegar en App Service con GitHub Actions, Microsoft Learn.
- azure/login, GitHub.