AzureBicepIaCDevOps

Deploy Azure Bicep with GitHub Actions (No Secrets)

Parveen Singh
October 21, 2026
14 min read
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 secretFederated credential
What GitHub storesA passwordThree IDs, none of them secret
ExpirySecret expires; you rotate itEach token lasts minutes and is never stored
If it leaksAnyone can sign in as your appThere's nothing reusable to leak
ScopeAny workflow that can read the secretOnly 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 environmentrepo:OWNER/REPO:ref:refs/heads/main
In a GitHub environment called devrepo:OWNER/REPO:environment:dev
For a pull requestrepo: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:

  • on sets when it runs: every push to main, plus a Run workflow button in the Actions tab from workflow_dispatch.
  • permissions: id-token: write lets 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/login gets three IDs and no creds block. The old JSON creds input is the client-secret method. Passing client-id, tenant-id and subscription-id on 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@v2 was 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, dev and prod.
  • 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 reaches main without review.
  • Open the prod environment, set Deployment branches and tags to Protected branches only, so production can only deploy from main.

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 rulePublic repoPrivate repo, Free/Pro/Team
Deployment branchesAll plansPro and Team
Required reviewersAll plansNot available
Wait timerAll plansNot 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-dev means production only starts after dev succeeds. If dev fails, production never runs.
  • environment: prod applies the prod environment's rules before the job starts.
  • prod.bicepparam deploys 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 for prod.
  • 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 dev credential, 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 as qa or staging.


The Series Wrap

That's the end of Bicep from Scratch. Over six episodes:

  1. What Bicep is and why it exists, and Bicep compared with Terraform.
  2. Your first template: a storage account, line by line.
  3. Parameters, variables and conditions, with a parameter file per environment.
  4. Modules: splitting the template into reusable pieces.
  5. Loops, dependencies and what-if: a full stack, previewed before it deploys.
  6. 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.

Recommended Readings