Deploy Azure Bicep with GitHub Actions (No Secrets)

For five episodes, every deployment in this series came from my laptop. That's fine while you're learning, but it stops working the moment a second person touches the project. Nobody can see what was deployed or when, nobody reviews it, and production depends on whoever happens to have the right terminal open.
The fix is a pipeline: push a change to GitHub, and GitHub Actions deploys it. The catch is that GitHub has to sign in to Azure somehow, and the way most older tutorials do it is to store a client secret in GitHub. This post sets it up the way Microsoft now recommends, with federated credentials, so there's no secret stored anywhere.
By the end you'll have:
- An app registration in Entra ID that trusts your GitHub repo, with no client secret.
- A workflow that runs what-if and then deploys to dev on every push to
main. - A production job that only runs after dev succeeds, behind its own GitHub environment.
- Fixes for the two login errors almost everyone hits on the first run. I hit both of them on camera.
The complete code is in the companion repo, singhparveen/bicep-cicd-pipeline. It's the full stack from episode five, a virtual network, an NSG, a storage account and a web app, plus the workflow and a SETUP.md with every command in this post.
Client Secrets vs Federated Credentials
GitHub Actions runs on a machine GitHub owns, and that machine has to prove to Azure who it is before it can deploy anything. There are two ways to do that.
The old way is a client secret. You create an app registration, generate a secret for it, and paste that secret into GitHub. It works, but now you have a password sitting in GitHub that expires, needs rotating, and can leak through a misconfigured workflow or a copy-pasted log.
The new way is a federated credential. Instead of sharing a password, Azure is told to trust tokens that GitHub issues for one specific repo. On every run, GitHub creates a short-lived signed token that says which repo and branch or environment the workflow is running from. Azure checks that token against the federated credential, and if it matches, it hands back an access token. Nothing long-lived is stored anywhere.
| Client secret | Federated credential | |
|---|---|---|
| What GitHub stores | A password | Three IDs, none of them secret |
| Expiry | Secret expires; you rotate it | Each token lasts minutes and is never stored |
| If it leaks | Anyone can sign in as your app | There's nothing reusable to leak |
| Scope | Any workflow that can read the secret | Only the exact repo, branch or environment you list |
This is OpenID Connect (OIDC) under the hood, and it's what Microsoft recommends for GitHub Actions. You'll see both names used for the same setup.
Step 1: Create the App Registration and Federated Credential
The app registration is the identity your workflow signs in as.
In the Azure portal:
- Open Microsoft Entra ID and select App registrations > New registration.
- Name it something descriptive, such as
github-bicep-deploy, keep single tenant, and select Register. - Copy the Application (client) ID and the Directory (tenant) ID from the overview page. You'll need both in GitHub.
- Go to Certificates & secrets > Federated credentials > Add credential.
- For the scenario, choose GitHub Actions deploying Azure resources.
- Fill in your GitHub organization (your username for a personal account), the repository, and the entity type, which is covered in detail below.
Or do the same thing with the Azure CLI:
APP_ID=$(az ad app create --display-name github-bicep-deploy --query appId -o tsv)
az ad sp create --id "$APP_ID"
The federated credential is where the important detail lives, which is the subject. The subject is the exact string GitHub puts in its token to describe where the workflow is running, and Azure only accepts the token if it matches a credential's subject character for character. It looks like this:
| The job runs... | GitHub sends this subject |
|---|---|
On a push to main, with no environment | repo:OWNER/REPO:ref:refs/heads/main |
In a GitHub environment called dev | repo:OWNER/REPO:environment:dev |
| For a pull request | repo:OWNER/REPO:pull_request |
Keep that table in mind, because both errors later in this post come down to it. One more detail: repos created on or after July 15, 2026, which includes a fork you make today, put numeric IDs into that repo: part, like repo:OWNER@123/REPO@456. Error 1 below explains why. The safest approach is to ask GitHub for your repo's exact prefix and build the subject from it:
SUB_PREFIX=$(gh api repos/OWNER/REPO/actions/oidc/customization/sub --jq .sub_claim_prefix)
az ad app federated-credential create --id "$APP_ID" --parameters "{
\"name\": \"github-main\",
\"issuer\": \"https://token.actions.githubusercontent.com\",
\"subject\": \"${SUB_PREFIX}:ref:refs/heads/main\",
\"audiences\": [\"api://AzureADTokenExchange\"]
}"
That's a credential for pushes to main, which is what the first version of the workflow needs.
Step 2: Give It the Contributor Role
The app can sign in now, but it can't do anything. Give it Contributor on the resource group it deploys to, and nothing wider:
SUBSCRIPTION_ID=$(az account show --query id -o tsv)
az role assignment create \
--assignee "$APP_ID" \
--role Contributor \
--scope "/subscriptions/$SUBSCRIPTION_ID/resourceGroups/rg-bicep-dev"
In the portal, that's the resource group's Access control (IAM) > Add role assignment, then pick Contributor and search for the app registration by name.
Pro tip: Scope the role to the resource groups the pipeline deploys to, not the whole subscription. If the workflow is ever misused, the damage stops at those resource groups. Repeat the assignment for the production resource group when you add it.
Step 3: Add Three IDs to GitHub Secrets
The workflow needs to know which app to sign in as, in which tenant, against which subscription. Add these three as repository secrets, under Settings > Secrets and variables > Actions, or with the GitHub CLI:
gh secret set AZURE_CLIENT_ID --body "$APP_ID"
gh secret set AZURE_TENANT_ID --body "$(az account show --query tenantId -o tsv)"
gh secret set AZURE_SUBSCRIPTION_ID --body "$(az account show --query id -o tsv)"
None of these is actually a secret. They're identifiers, and knowing them doesn't let anyone sign in. They live in Secrets so they stay out of your logs and out of the workflow file, and Microsoft's docs recommend it. The thing that would normally be the password, the client secret, doesn't exist in this setup at all.
Step 4: The Workflow File
Workflows live in .github/workflows/ in your repository. Create deploy.yml:
name: Deploy Bicep
on:
push:
branches: [main]
workflow_dispatch:
permissions:
id-token: write
contents: read
env:
DEV_RESOURCE_GROUP: rg-bicep-dev
jobs:
deploy-dev:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Azure Login
uses: azure/login@v3
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: What-If
uses: azure/arm-deploy@v2
with:
resourceGroupName: ${{ env.DEV_RESOURCE_GROUP }}
template: ./main.bicep
parameters: ./dev.bicepparam
additionalArguments: --what-if
- name: Deploy
uses: azure/arm-deploy@v2
with:
resourceGroupName: ${{ env.DEV_RESOURCE_GROUP }}
template: ./main.bicep
parameters: ./dev.bicepparam
Here's what each part does:
onsets when it runs: every push tomain, plus a Run workflow button in the Actions tab fromworkflow_dispatch.permissions: id-token: writelets the job ask GitHub for an OIDC token. This is the line people miss. Without it, the login step has no token to exchange and fails, no matter how perfectly the Azure side is configured.azure/logingets three IDs and nocredsblock. The old JSONcredsinput is the client-secret method. Passingclient-id,tenant-idandsubscription-idon their own tells the action to use OIDC.- The what-if step runs the same what-if from episode five, and its output lands in the job log, so anyone can see what's about to change. If the template has an error, the job stops here, before anything deploys.
- The deploy step is the same call without
--what-if.
Gotcha:
azure/arm-deploy@v2was built for Node 20, which GitHub retired from Actions runners in September 2026. GitHub now runs it on Node 24 and logs a deprecation warning in the job, but the deployment works. Microsoft's newer azure/bicep-deploy action runs on Node 24 natively if you'd rather avoid the warning.
Error 1: The Subject Doesn't Match
Commit the workflow, push it, and open the Actions tab. There's a good chance the first run fails at the login step with this:
AADSTS700213: No matching federated identity record found for presented assertion subject
'repo:singhparveen/bicep-cicd-pipeline:ref:refs/heads/main'. Check your federated identity
credential Subject, Audience and Issuer against the presented assertion.
This error is more helpful than it looks, because it tells you the exact subject GitHub sent. The fix is always to make the federated credential's subject match that string exactly.
When I recorded this episode, the mismatch came from a recent change. GitHub now has an immutable subject format that adds the numeric owner and repo IDs, like repo:singhparveen@42755757/bicep-cicd-pipeline@1282665726:ref:refs/heads/main. Because names can be renamed and reused, the IDs make sure a credential stays tied to the original repo. The Azure portal's GitHub form now asks for those IDs. My repo, though, was created before GitHub switched new repos to the immutable format on July 15, 2026, so it was still sending the name-only subject. The credential expected IDs, the token didn't have them, and login failed. Removing the IDs from the credential made it match, and the next run signed in fine.
You can check which format your repo sends before you create any credentials:
gh api repos/OWNER/REPO/actions/oidc/customization/sub --jq .sub_claim_prefix
The repo I recorded with printed the name-only prefix. The companion repo, which was created after July 15, prints the immutable one:
repo:singhparveen/bicep-cicd-pipeline # repo created before July 15, 2026
repo:singhparveen@42755757/bicep-cicd-pipeline@1420333188 # repo created after it
- A name-only prefix means use names only in the subject.
- A prefix with
@and IDs means your repo uses the immutable format, so include the IDs.
Pro tip: The immutable format is the more secure choice, because a credential bound to IDs can't be satisfied by a different repo that later takes the same name. For an older repo, you can opt in from the repository's OIDC settings, then create credentials with the immutable subject. Microsoft has a migration guide that covers doing it without downtime.
Once the subject matches, rerun the job. The log shows the login succeeding with OIDC, then the what-if output, which for a fresh resource group was Resource changes: 5 to create, and then the deploy.
Protecting Production with Environments
Right now every push to main deploys, which is fine for dev and a bad idea for production. GitHub environments are how you put rules in front of a deployment.
In the repository settings:
- Go to Settings > Environments and create two environments,
devandprod. - Go to Settings > Branches and add a protection rule for
main. Turn on Require a pull request before merging with at least one approval, so nothing reachesmainwithout review. - Open the
prodenvironment, set Deployment branches and tags to Protected branches only, so production can only deploy frommain.
There's one more rule you'll probably want, Required reviewers, which makes the production job pause until a named person approves it. Check your plan first:
| Protection rule | Public repo | Private repo, Free/Pro/Team |
|---|---|---|
| Deployment branches | All plans | Pro and Team |
| Required reviewers | All plans | Not available |
| Wait timer | All plans | Not available |
On a private repo without GitHub Enterprise, branch protection on main is your approval gate: nothing merges to main without an approved pull request, and production only deploys from main. On a public repo, add yourself or your team as required reviewers on prod as well.
Now add the production job to the workflow, and tie both jobs to their environments:
env:
DEV_RESOURCE_GROUP: rg-bicep-dev
PROD_RESOURCE_GROUP: rg-bicep-prod
jobs:
deploy-dev:
runs-on: ubuntu-latest
environment: dev
steps:
# ...same steps as before...
deploy-prod:
runs-on: ubuntu-latest
needs: deploy-dev
environment: prod
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Azure Login
uses: azure/login@v3
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: What-If Production
uses: azure/arm-deploy@v2
with:
resourceGroupName: ${{ env.PROD_RESOURCE_GROUP }}
template: ./main.bicep
parameters: ./prod.bicepparam
additionalArguments: --what-if
- name: Deploy Production
uses: azure/arm-deploy@v2
with:
resourceGroupName: ${{ env.PROD_RESOURCE_GROUP }}
template: ./main.bicep
parameters: ./prod.bicepparam
Three lines do the work here:
needs: deploy-devmeans production only starts after dev succeeds. If dev fails, production never runs.environment: prodapplies theprodenvironment's rules before the job starts.prod.bicepparamdeploys the same template with production values to a different resource group. This is the payoff from the parameter files in episode three: one template, two environments, nothing duplicated.
Error 2: The Environment Subject
Push that change, and the dev job, which worked a minute ago, fails with the same error code:
AADSTS700213: No matching federated identity record found for presented assertion subject
'repo:singhparveen/bicep-cicd-pipeline:environment:dev'. Check your federated identity
credential Subject, Audience and Issuer against the presented assertion.
Look at the subject. It no longer ends in :ref:refs/heads/main. As soon as a job declares an environment, GitHub sends the environment subject instead of the branch subject. The only credential in Azure was for the branch, so nothing matches.
This one catches nearly everyone, because nothing about the Azure setup changed. You only added one line to the workflow.
The fix is a federated credential for each environment:
- In the portal, add a credential with the entity type set to Environment and the name
dev, then add another forprod. - Or with the CLI, create one per environment:
for ENV in dev prod; do
az ad app federated-credential create --id "$APP_ID" --parameters "{
\"name\": \"github-env-$ENV\",
\"issuer\": \"https://token.actions.githubusercontent.com\",
\"subject\": \"${SUB_PREFIX}:environment:$ENV\",
\"audiences\": [\"api://AzureADTokenExchange\"]
}"
done
Once both jobs use environments, you can delete the branch credential, because no job sends that subject anymore. Rerun the workflow and the dev job signs in, runs what-if and deploys. Production then waits for dev and for the prod environment's rules.
Warning: If you only add the
devcredential, dev goes green and then production fails with the same error, this time naming:environment:prod. Create a credential for every environment your workflow uses, including ones you add later, such asqaorstaging.
The Series Wrap
That's the end of Bicep from Scratch. Over six episodes:
- What Bicep is and why it exists, and Bicep compared with Terraform.
- Your first template: a storage account, line by line.
- Parameters, variables and conditions, with a parameter file per environment.
- Modules: splitting the template into reusable pieces.
- Loops, dependencies and what-if: a full stack, previewed before it deploys.
- A pipeline that deploys it all from GitHub, with no secrets stored anywhere.
The hub page for the whole series is Azure Bicep from Scratch. If parts of this post felt new, Git vs GitHub vs GitHub Actions covers how the three fit together, and if you want to go further than branch protection, testing Bicep before it breaks production adds PSRule checks to the pipeline.
The lesson I'd take from this episode isn't the YAML. Both errors were the same error: Azure trusts an exact subject, and GitHub's subject changes depending on how a job runs. Once you know to read the subject out of the error message and compare it with the credential, federated credentials stop being mysterious, and you'll never paste a client secret into GitHub again.




