Federating GitHub Actions to Azure with OIDC — no more client secrets
A walkthrough of how to deploy from GitHub Actions to Azure without storing a client secret anywhere. Faster, safer, easier to rotate.
If you're still pasting an AZURE_CREDENTIALS JSON blob into your GitHub repo secrets, stop. There's a better pattern that's been generally available for years now: OIDC federated credentials.
The short version: GitHub Actions issues a short-lived JWT for each workflow run. Azure trusts that JWT, exchanges it for an access token, and your job runs with no long-lived secret stored anywhere.
Here's how to set it up end-to-end.
What you're building
GitHub Actions workflow
│ (issues OIDC token claiming "I am repo X, branch main")
▼
Azure AD app registration
│ (federated credential trusts that exact claim)
▼
Service principal with RBAC on your Azure resources
No client secret. No credential rotation. Permissions scoped to a specific repo and branch.
Step 1: Create the Azure AD app + service principal
APP_NAME="sp-github-myrepo-deploy"
# Create the app registration and grab its IDs
APP_ID=$(az ad app create --display-name "$APP_NAME" --query appId -o tsv)
az ad sp create --id "$APP_ID"
# Grant Contributor at the resource group scope (or scope tighter)
SUB_ID=$(az account show --query id -o tsv)
az role assignment create \
--role contributor \
--subscription "$SUB_ID" \
--assignee "$APP_ID" \
--scope "/subscriptions/$SUB_ID/resourceGroups/$RG_NAME"
Use the principle of least privilege — Contributor at the resource group level, not the subscription level.
Step 2: Create the federated credential
This is the magic step. You're telling Azure AD: "trust GitHub-issued tokens, but only when they claim to come from this exact repo and branch."
APP_OBJ_ID=$(az ad app show --id "$APP_ID" --query id -o tsv)
cat > /tmp/fic.json <<EOF
{
"name": "github-myorg-myrepo-main",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:myorg/myrepo:ref:refs/heads/main",
"audiences": ["api://AzureADTokenExchange"]
}
EOF
az ad app federated-credential create --id "$APP_OBJ_ID" --parameters /tmp/fic.json
The subject claim is the lock — only workflows running from myorg/myrepo on the main branch can use this. You can also scope to a specific environment, pull request, or tag.
Step 3: Add the IDs to your GitHub repo
Settings → Secrets and variables → Actions → New repository secret:
AZURE_CLIENT_ID— theappIdfrom step 1AZURE_TENANT_ID—az account show --query tenantId -o tsvAZURE_SUBSCRIPTION_ID—az account show --query id -o tsv
These aren't really "secrets" — they're identifiers. But putting them in repo secrets is the cleanest pattern.
Step 4: Configure the workflow
permissions:
id-token: write # required for OIDC
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Login to Azure
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Run an Azure CLI command
run: az account show
That's it. No creds: parameter, no JSON blob, no client secret.
A few things people get wrong
Forgetting id-token: write. Without that permission, the OIDC token isn't issued. The error is confusing — usually "AADSTS70021" or similar. If you see that, check your permissions block.
Subject claim mismatch. The federated credential's subject must exactly match what GitHub will claim. If you scope to ref:refs/heads/main and someone tries to deploy from a feature branch, it'll fail — which is exactly what you want, but easy to debug-fix the wrong way.
Scoping for environments. If you use GitHub Environments, the subject changes to repo:myorg/myrepo:environment:production. Add a separate federated credential per environment so prod and staging deploys can have different Azure permissions.
Why bother?
Three reasons, in order of importance:
- Nothing to leak. A GitHub repo compromise can't expose a long-lived Azure credential because there isn't one.
- No rotation. Federated credentials don't expire on a schedule.
- Fine-grained scoping. You can grant deploy access to "this repo on the main branch only" — much tighter than a service principal credential.
If you're still using AZURE_CREDENTIALS JSON in your workflows, this is a 30-minute migration that genuinely makes you safer. Worth your Friday afternoon.