Guardrail: OIDC Federation for GitHub Actions → Azure¶
Status: OIDC required by default Scope: All GitHub Actions workflows that authenticate to Azure Issue: #56
Rule¶
GitHub Actions workflows authenticating to Azure MUST use OpenID Connect
(OIDC) federated credentials via azure/login@v2 by default.
Stored service-principal credentials are an exception that must be explicitly
documented and kept in GitHub Secrets. The portal deployment workflow supports
the temporary AZURE_CREDENTIALS fallback while selecting OIDC whenever that
secret is absent. New workflows must not add stored-credential authentication
without an approved operational requirement.
How OIDC Works with GitHub Actions + Azure¶
- GitHub mints a short-lived OIDC token for each workflow run, signed by GitHub's OIDC provider (
token.actions.githubusercontent.comor enterprise-scoped issuer such astoken.actions.githubusercontent.com/<enterprise-slug>). - Azure Entra ID validates the token against a federated credential configured on an app registration (or managed identity). It checks the
issuer,subject, andaudienceclaims. - Azure issues a short-lived access token scoped to the permissions granted to the app registration — no long-lived secret is ever stored or transmitted.
The entire flow is secretless. GitHub never sees an Azure credential, and Azure never stores a GitHub credential.
Bootstrap Pattern¶
1. Define the Entra identity in IaC¶
Create the app registration, service principal, federated credentials, and least-privilege role assignments through the repository's IaC. Do not create or change Azure identities ad hoc through the CLI or portal.
2. Add federated credentials¶
Define one federated credential per branch or environment that needs access.
Set the issuer in the IaC definition to
https://token.actions.githubusercontent.com, or to the enterprise-scoped
issuer when an enterprise custom issuer policy is enabled:
For the main branch:
{
"name": "main-branch",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:ivegamsft/basecoat:ref:refs/heads/main",
"audiences": ["api://AzureADTokenExchange"]
}
For pull requests:
{
"name": "pull-requests",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:ivegamsft/basecoat:pull_request",
"audiences": ["api://AzureADTokenExchange"]
}
For a specific environment:
{
"name": "production-env",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:ivegamsft/basecoat:environment:production",
"audiences": ["api://AzureADTokenExchange"]
}
3. Store identifiers as repository variables¶
Only non-secret identifiers are stored — no passwords or keys:
| Repository variable | Value |
|---|---|
AZURE_CLIENT_ID |
App registration Application (client) ID |
AZURE_TENANT_ID |
Entra ID (Azure AD) tenant ID |
AZURE_SUBSCRIPTION_ID |
Target Azure subscription ID |
Example Workflow Step¶
permissions:
id-token: write # Required for OIDC token request
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- run: az account show
Critical: The
permissions.id-token: writeblock is required. Without it, GitHub will not issue the OIDC token and the login step will fail.
Why OIDC Is Preferred¶
| Risk | Description |
|---|---|
| Rotation burden | Client secrets expire and must be manually rotated. Missed rotations cause outages. |
| Secret sprawl | Secrets copied across repos, environments, and developer machines multiply the attack surface. |
| Exfiltration exposure | A compromised workflow can exfiltrate a stored secret. OIDC tokens are audience-bound and expire in minutes. |
| Lateral movement | A leaked client secret can be used from any network. OIDC tokens are bound to a specific repo, branch, and workflow run. |
| Audit gap | Secret usage is hard to attribute. OIDC claims provide exact provenance (repo, branch, commit, actor). |