Skip to content
CAMPUX Cloud Bootcamp Lab · CI/CD ← All labs
Hands-On Lab · Intermediate
~40 min · Azure costs vary · Cloud Shell + GitHub
CLI & Actions · tear down at the end
CI/CD · Pipelines

GitHub Actions to Azure, with no stored Azure client secret.

Replace a stored Azure client secret with a signed identity token requested by the workflow at run time. Entra checks the token against a narrow trust, then Azure RBAC limits what the job can do.

Fig. 1 · GitHub Actions → Azure with OIDC
OIDC token requestshort-lived tokenGitHub Actionsworkflow runFederated credentialtrusts this repo + branchAzurescoped, least-privilege roleno stored password — a fresh token per run
  1. 1. GitHub signs the runThe job requests a short-lived OIDC token identifying its repository and main branch.
  2. 2. Entra checks the trustIssuer, subject, and audience must match the app's federated credential.
  3. 3. Azure limits accessEntra issues short-lived access; Contributor applies only to this lab resource group.
Start

Run one step, then check it

Read the step before copying. Bash commands belong in Git Bash on Windows or Azure Cloud Shell set to Bash; PowerShell uses different syntax. A JSON, YAML, Bicep or application-code example belongs in the named file, not directly at the terminal prompt. Follow any shell-specific instructions below.

Keep the same terminal open so earlier variables remain available. Copy one complete command block without the terminal's $ prompt. A backslash continues a command onto the next line. If Bash shows a lone >, it is waiting for the rest of a quote, command or file block. Finish the complete block; if the paste was incomplete, press Ctrl+C and copy it again. Do not paste the next step into an unfinished command.

A line such as RG="training-group" stores a value without printing it. Output captured with $(...) is also stored; a blank screen alone does not prove failure. A sleep deliberately waits, and -o none suppresses normal output. Wait for your normal prompt, then run the step's checkpoint. After an error, stop and keep the exact command and message; later steps cannot repair a missing resource. Never share passwords, access tokens or secret values in a screenshot.

Why

Trust the caller, not a saved password

A stored Azure client secret can be reused until it expires or is revoked. OIDC federation lets a GitHub job request a signed token identifying its run. Entra checks that token against a federated credential and issues short-lived Azure access. This removes the stored client secret, but a live token can still be misused until it expires. Review the workflow and keep tokens out of logs.

You will create an app registration, grant it Contributor on one newly created lab resource group, trust only your repository's main branch, and deploy a small Bicep template. The three Azure IDs identify the target but do not authenticate on their own.

A stored secret is a liability with a shelf life. A federated token is a handshake that expires in minutes.

Before you begin — one-time setup

You need an Azure subscription with permission to create resources and assign a role, plus Azure CLI access. The setup guide covers local sign-in. You can use Azure Cloud Shell (Bash) instead of installing tools locally. An Azure free account may provide credits, but deployed resources can still incur charges.

What you need

A GitHub repository you own, Azure Cloud Shell (Bash), permission to create an Entra app and service principal, and permission to assign Contributor at the new resource group's scope. Account type alone does not guarantee these rights. Copy bicep/main.bicep from the CAMPUX lab repository to the same path in your repository. Replace OWNER/REPO with the exact case-sensitive owner and repository name in your GitHub URL.

Setup

An identity for the pipeline

Create an app registration and its service principal — the identity your workflow will act as — then capture the IDs you will need.

Commands inside $(...) save their output in a variable. Check the printed identifier or the checkpoint before continuing.

# Azure Cloud Shell (Bash)
SUB_ID=$(az account show --query id -o tsv)
TENANT_ID=$(az account show --query tenantId -o tsv)

APP_ID=$(az ad app create --display-name "campux-gh-oidc-$RANDOM" --query appId -o tsv)
SP_OBJECT_ID=$(az ad sp create --id "$APP_ID" --query id -o tsv)

echo "client-id (appId): $APP_ID"
echo "tenant-id:         $TENANT_ID"
echo "subscription-id:   $SUB_ID"
Checkpoint You have three IDs printed back. None of them is a secret — they identify the app but grant nothing on their own. Access comes from the role you assign next, and sign-in comes from the federation you configure after that.
Step 1

Grant a scoped, least-privilege role

Make a resource group for the pipeline to deploy into, and give the app Contributor only on that group — never the whole subscription.

Commands inside $(...) save their output in a variable. Check the printed identifier or the checkpoint before continuing.

RG="campux-oidc-$RANDOM-$RANDOM"
RG_ID=$(az group create -n "$RG" -l eastus --query id -o tsv)

az role assignment create \
  --assignee-object-id "$SP_OBJECT_ID" \
  --assignee-principal-type ServicePrincipal \
  --role Contributor \
  --scope "$RG_ID"
printf 'Lab resource group: %s\n' "$RG"
Checkpoint Confirm the assignment's scope is the new lab group. This role still allows creation and deletion inside that group; review the workflow before merging it. If role assignment fails, verify your permission and allow for Entra replication rather than widening the role.
Step 2

Tell Azure which workflow to trust

Add a federated identity credential. The subject is the exact identity GitHub will present; here it means "a run of OWNER/REPO on the main branch." The audience is the fixed value Azure expects.

OWNER="<your-github-name>"; REPO="<your-repo>"
SUB_PREFIX=$(curl -s "https://api.github.com/repos/$OWNER/$REPO/actions/oidc/customization/sub" \
  | sed -n 's/.*"sub_claim_prefix": *"\([^"]*\)".*/\1/p')
echo "$SUB_PREFIX"   # e.g. repo:you@12345678/campux-platform@987654321
az ad app federated-credential create --id "$APP_ID" --parameters "{
  \"name\": \"gh-main\",
  \"issuer\": \"https://token.actions.githubusercontent.com\",
  \"subject\": \"$SUB_PREFIX:ref:refs/heads/main\",
  \"audiences\": [\"api://AzureADTokenExchange\"]
}"
Checkpoint Set OWNER and REPO before running the block, and check the printed prefix is not empty. GitHub now writes the owner and repository into the subject with their numeric IDs, repo:you@12345678/campux-platform@987654321:ref:refs/heads/main, on new repositories and on many older ones. A subject typed by hand as repo:you/campux-platform:... no longer matches, and the login fails with AADSTS700213. Reading the prefix from GitHub gives the exact form your repository uses. For a private repository the lookup needs your sign-in: gh api repos/$OWNER/$REPO/actions/oidc/customization/sub --jq .sub_claim_prefix. We found this when running the lab in October 2026: the hand-typed subject was refused even on main. The subject matches a job on main without a GitHub environment; a different branch or pull-request subject will not match this credential.
Step 3

Store the IDs and lab-group name

The Azure Login action recommends passing its three ID inputs through GitHub Secrets. They are identifiers rather than passwords, but avoid needlessly publishing tenant details. Store the lab resource-group name as a plain repository variable. Use Settings → Secrets and variables → Actions in GitHub, or use the authenticated gh CLI from a clone of your repository:

# with the GitHub CLI, from a clone of your repo
gh secret set AZURE_CLIENT_ID --body "$APP_ID"
gh secret set AZURE_TENANT_ID --body "$TENANT_ID"
gh secret set AZURE_SUBSCRIPTION_ID --body "$SUB_ID"
gh variable set LAB_RG_NAME --body "$RG"
Checkpoint The three IDs appear as GitHub secret names and the group name appears as a variable. There is no AZURE_CLIENT_SECRET.
Step 4

The workflow

The workflow needs id-token: write, Azure Login, and the checked-out Bicep file. Save this as .github/workflows/deploy.yml. Review the exact file and resource-group variable before merging it to main, because a push to main triggers a real Azure deployment.

name: deploy
on:
  push:
    branches: [ main ]

permissions:
  id-token: write        # lets the run request an OIDC token
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7

      - name: Azure login (OIDC)
        uses: azure/login@v3
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Deploy a storage account
        env:
          LAB_RG_NAME: ${{ vars.LAB_RG_NAME }}
        run: |
          az deployment group create \
            --resource-group "$LAB_RG_NAME" \
            --template-file bicep/main.bicep

actions/checkout@v7 places bicep/main.bicep on the runner. --template-file lets Azure CLI compile the local Bicep. The group name is passed through an environment variable so it is not inserted directly into shell source. If the template creates a storage account, Azure charges for resources that remain after the lab.

Checkpoint Merge the reviewed PR to main, then inspect the Actions run. Check both the login step and the deployment result; a failed or skipped run does not prove the lab worked. Keep the live access token out of logs and complete teardown when finished.
Verify

Prove it was real — and secret-free

Back in Cloud Shell, confirm the workflow actually created the resource, and confirm there is no secret behind it.

az resource list -g "$RG" --query "[].{name:name,type:type}" -o table
az ad app credential list --id "$APP_ID" --query "length(@)"   # -> 0 client secrets
Checkpoint The resource list shows the storage account the pipeline created, and the credential count is 0 — the app has no password at all. Authentication came entirely from the federated trust you configured. That is the sentence to say in an interview.
Down

Tear it down

In the same Cloud Shell session, check the printed $RG and $APP_ID values before deleting anything. Delete only the lab objects you created.

printf 'Deleting lab group %s and app %s\n' "$RG" "$APP_ID"
az group delete -n "$RG" --yes
az ad sp delete --id "$APP_ID"
az ad app delete --id "$APP_ID"
az group exists -n "$RG"      # -> false
Checkpoint Confirm the group is gone. Remove the three lab ID secrets and LAB_RG_NAME variable from GitHub, and delete the practice workflow through a reviewed PR so later pushes do not try to redeploy.
End

What you can now honestly claim

If your workflow succeeded and you verified the created resource, you can say you connected GitHub Actions to Entra with OIDC, assigned Contributor on a single lab resource group, and deployed Bicep without storing an Azure client secret. You also checked the app's credential list and removed the lab resources. That is a concrete, reviewable demonstration; it is not a production security certification.

Footnotes
  1. The subject must match GitHub's token exactly. For a branch it is $SUB_PREFIX:ref:refs/heads/BRANCH, where the prefix comes from the lookup in Step 2; to gate deploys behind a GitHub Environment instead, use $SUB_PREFIX:environment:NAME and add a second federated credential. A mismatch here is the single most common reason an OIDC login is refused.
  2. The client, tenant, and subscription IDs are identifiers, not a client secret. This lab follows Azure Login's recommendation to pass them from GitHub Secrets. A matching signed OIDC token and Azure RBAC are still required for access.