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.
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.
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.
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.
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"
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"
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.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\"]
}"
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.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"
AZURE_CLIENT_SECRET.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.
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
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.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
LAB_RG_NAME variable from GitHub, and delete the practice workflow through a reviewed PR so later pushes do not try to redeploy.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.
- The
subjectmust 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:NAMEand add a second federated credential. A mismatch here is the single most common reason an OIDC login is refused. - 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.