Deploy to Azure from GitHub Actions without secrets (OIDC)

Let a GitHub Actions workflow sign in to Azure with workload identity federation — choose a user-assigned managed identity or an app registration, assign the role at the narrowest scope, create the federated credential with the subject the job really presents (environment, branch, tag, pull request, and the 2026 immutable format), log in with azure/login@v3, and use the session from the az CLI and from TypeScript.

markdownguidev19X2M-1BJ7stage: buildtrust: officialservice: entraservice: managed-identityinterface: cliinterface: ciinterface: sdkGitHub Actions Azure OIDCworkload identity federation GitHubazure/login v3federated credential GitHubid-token writeAZURE_CLIENT_ID AZURE_TENANT_ID AZURE_SUBSCRIPTION_IDaz identity federated-credential createaz ad app federated-credential createuser-assigned managed identity GitHubsecretless deploymentimmutable subject GitHuballow-no-subscriptionsDefaultAzureCredential in GitHub ActionsAZURE_TOKEN_CREDENTIALS

Created 21 Oct 2026 · Modified 23 Oct 2026 · 1 version

Deploy to Azure from GitHub Actions without secrets

The workflow asks GitHub for an OIDC token, and Microsoft Entra exchanges it for an Azure token because a federated credential on your identity trusts that exact issuer, subject and audience. No secret is stored anywhere. Almost every failure is a subject that doesn't match what the job presents.

1. Pick the identity

User-assigned managed identityApp registration (service principal)
Created withaz identity create (an Azure resource, in a resource group)az ad app create + az ad sp create (Entra objects)
Who can create itRights to create resources in the resource group (for example Managed Identity Contributor)App-registration rights in the tenant
Use whenDeploying Azure resources (the usual case)The workflow also needs Microsoft Graph application permissions

Both take up to 20 federated credentials and work with azure/login.

2. Create it and give it a role

az identity create --name gh-deploy --resource-group rg-app --location <region>
# note clientId and principalId from the output

az role assignment create \
  --assignee-object-id <principalId> --assignee-principal-type ServicePrincipal \
  --role Contributor \
  --scope /subscriptions/<sub-id>/resourceGroups/rg-app

Use --assignee-object-id with --assignee-principal-type: it skips the directory lookup that fails for a service principal caller (Insufficient privileges to complete the operation) and avoids PrincipalNotFound while a new identity replicates. Contributor can't create role assignments (its NotActions exclude Microsoft.Authorization/*/Write). If the workflow deploys them, add Role Based Access Control Administrator at the same scope, ideally with a condition limiting which roles it may assign (reference/rbac/role-assignments-and-scopes.md).

3. Add the federated credential for the subject the job presents

The job…--subject
has environment: productionrepo:<org>/<repo>:environment:production
runs on a push to mainrepo:<org>/<repo>:ref:refs/heads/main
runs on a tag v2repo:<org>/<repo>:ref:refs/tags/v2
runs on pull_requestrepo:<org>/<repo>:pull-request
az identity federated-credential create \
  --name gh-production --identity-name gh-deploy --resource-group rg-app \
  --issuer https://token.actions.githubusercontent.com \
  --subject repo:contoso/payments-api:environment:production \
  --audiences api://AzureADTokenExchange

For an app registration: az ad app federated-credential create --id <client-id> --parameters credential.json, where the file holds name, issuer, subject and audiences.

  • Prefer environments. Branch and tag patterns aren't supported, and an environment gives one subject for every branch that deploys to it.
  • Casing matters (case-sensitive since 2024). Copy names exactly.
  • Immutable subjects: a repository created, renamed or transferred after July 15, 2026 presents repo:<org>@<org-id>/<repo>@<repo-id>:…. Older repositories present the name-based form until they opt in. When unsure, run the workflow once and copy the subject from the log (step 5).
  • Wait a few minutes after creating a credential before the first run; early runs can fail with AADSTS70021.
  • Creating several credentials on one identity at once returns 409: create them one after another.

4. The workflow

Put the IDs in repository or environment variables (they aren't secrets, but azure/login@v3 masks the client ID in logs by default):

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v7
      - uses: azure/login@v3
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
      - run: az group show --name rg-app --query properties.provisioningState
  • No subscription-id? Set allow-no-subscriptions: true, or the action fails with Ensure 'subscription-id' is supplied or 'allow-no-subscriptions' is 'true'.
  • Pass only vars.* or secrets.* into these inputs, never values from github.event.*.

5. Check what was presented

The azure/login step logs Federated token details: with the issuer, subject claim and audience the job sent. When login fails, compare them with az identity federated-credential list --identity-name gh-deploy --resource-group rg-app. The error catalogue is reference/entra/federated-credential-errors.md.

6. TypeScript in the same job

After azure/login, the Azure CLI holds the session, so DefaultAzureCredential succeeds through AzureCliCredential. Its chain tries Environment, Workload Identity and Managed Identity first; on a GitHub-hosted runner those find nothing. Pin the credential so the job doesn't probe for a managed identity:

import { DefaultAzureCredential } from "@azure/identity";
import { ResourceManagementClient } from "@azure/arm-resources";

// In the workflow step: env: AZURE_TOKEN_CREDENTIALS: AzureCliCredential
const credential = new DefaultAzureCredential({ requiredEnvVars: ["AZURE_TOKEN_CREDENTIALS"] });
const client = new ResourceManagementClient(credential, process.env.AZURE_SUBSCRIPTION_ID ?? "");

const rg = await client.resourceGroups.get("rg-app");
console.log(rg.properties?.provisioningState);

AZURE_TOKEN_CREDENTIALS takes dev, prod or one credential name. Without requiredEnvVars, a missing variable silently falls back to the full chain.

Next

Errors: reference/entra/federated-credential-errors.md. The full handoff with known-good and known-bad configurations: patterns/github-actions-to-azure.md.

Read this document as markdown — the same bytes an agent receives.