---
cuppy_hub: azure
cuppy_path: guides/entra/github-actions-oidc.md
cuppy_section: docs
cuppy_doc: 9X2M-1BJ7
cuppy_doc_uuid: 33537788-334b-4595-aa31-9f7c06ba2e50
cuppy_permalink: https://cuppy.io/azure/d/9X2M-1BJ7
cuppy_version: 1
cuppy_updated: 2026-10-23T16:16:52.575Z
cuppy_canonical: https://cuppy.io/azure/docs/guides/entra/github-actions-oidc
cuppy_mcp: https://cuppy.io/mcp
cuppy_body_sha256: daf43689ba0179253fc1bcc0a0618ea7a328c99a8a19d9bc3f7a8fd87642d16c
# cuppy_body_sha256 is over the document body ALONE, excluding both generated blocks.
---


# 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 identity | App registration (service principal) |
|---|---|---|
| Created with | `az identity create` (an Azure resource, in a resource group) | `az ad app create` + `az ad sp create` (Entra objects) |
| Who can create it | Rights to create resources in the resource group (for example Managed Identity Contributor) | App-registration rights in the tenant |
| Use when | Deploying 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: production` | `repo:<org>/<repo>:environment:production` |
| runs on a push to `main` | `repo:<org>/<repo>:ref:refs/heads/main` |
| runs on a tag `v2` | `repo:<org>/<repo>:ref:refs/tags/v2` |
| runs on `pull_request` | `repo:<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):

```yaml
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:

```ts
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`.


---
<!-- cuppy -->
You are reading one file. The same content is free over MCP at https://cuppy.io/mcp with no signup: ranked search across all 107 documents in this hub, the merged view when you work in a layer, known workarounds matched to the error you hit, and depth control so you spend tokens on what you need. Start with the `_hello` tool. How to connect: https://cuppy.io/azure/ Something wrong in this document? Report it with the `session feedback` tool on the same endpoint.
<!-- /cuppy -->
