Assign a role to a managed identity and prove access works with no Azure client secret stored in the app.
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.
Managed identity and least privilege, in one task
Think of the app’s identity as its own badge. Enabling it issues the badge; the data role grants entry to this vault. In this lab App Service resolves a Key Vault reference using its system-assigned identity, then supplies the harmless training value to the running app. Your sign-in creates the resources; the application’s identity performs the reference lookup.
An identity still needs permission.
You need a free Azure account, the Azure CLI (az), then be signed in with az login. First time? The 15-minute Set up your machine page covers the account, the installs (winget / brew / apt), and sign-in. Prefer zero installs? Run everything in Azure Cloud Shell (Bash), preinstalled and already signed in.
Run everything in Azure Cloud Shell (Bash). The tiny app and a Bicep version of this whole setup are at github.com/kloudcaptain/campux-labs.
What you need
You need permission to assign roles — Owner or User Access Administrator on the subscription. A plain Contributor cannot, and the role commands below will fail with AuthorizationFailed. The teardown's last step, az keyvault purge, needs more than that: only a subscription Owner or a principal holding Key Vault Purge Operator can purge a vault, so User Access Administrator alone is not enough to finish the teardown. On your own subscription you are Owner, which covers both. Run it all in Cloud Shell (Bash); az, zip, and curl are already there.
Use the F1 plan only where supported and available in your subscription; Key Vault operations can incur charges. Check current Azure pricing and quotas. Resource creation and role-assignment permission are separate; User Access Administrator alone does not grant permission to create the app or vault. Use a disposable group and delete it when finished.
Variables
Paste this once. $RANDOM gives every resource a unique suffix so global names never collide. The first two lines are one-time guards explained in the box below.
Commands inside $(...) save their output in a variable. Check the printed identifier or the checkpoint before continuing.
# Windows/Git Bash: stop it rewriting /subscriptions/... arguments (harmless on macOS/Linux) export MSYS_NO_PATHCONV=1 # if you have more than one subscription, make the intended one active az account set --subscription "$(az account show --query id -o tsv)" SUFFIX=$RANDOM RG="campux-lab-rbac-$SUFFIX-rg" LOCATION="eastus" KV="campux-kv-$SUFFIX" # 3-24 chars, starts with a letter APP="campux-api-$SUFFIX" # globally unique across *.azurewebsites.net PLAN="campux-plan-$SUFFIX" SECRET_NAME="ProductDbConnection" SUB=$(az account show --query id -o tsv) VAULT_SCOPE="/subscriptions/$SUB/resourceGroups/$RG/providers/Microsoft.KeyVault/vaults/$KV" echo "Suffix=$SUFFIX KV=$KV APP=$APP SUB=$SUB"
echo prints your names and a non-empty SUB. If $SUB is empty you are not signed in — run az login.export MSYS_NO_PATHCONV=1 — on Windows, Git Bash silently rewrites any argument starting with / into a Windows path before handing it to az, so --scope /subscriptions/… arrives mangled and Azure replies MissingSubscription. This turns that off for the session. It does nothing on macOS/Linux, so it is safe to always run. az account set — if your login has more than one subscription (a personal Azure account often does), this makes sure commands act on the one you mean. Both are one-liners you set once per terminal.
Resource group and an RBAC-mode Key Vault
az group create --name "$RG" --location "$LOCATION" az keyvault create --name "$KV" --resource-group "$RG" \ --location "$LOCATION" --enable-rbac-authorization true
az keyvault show --name "$KV" --query "properties.enableRbacAuthorization" -o tsv prints true. That flag makes the vault use Azure roles instead of legacy access policies — the whole premise of the lab.Grant yourself, then create the secret
An RBAC vault does not let its creator read or write secret data automatically. Grant yourself the data-plane role first — this surprises almost everyone.
Commands inside $(...) save their output in a variable. Check the printed identifier or the checkpoint before continuing. This block includes a deliberate wait. Leave this terminal open until the normal prompt returns; a wait is not proof that access has propagated.
# your user object id — the normal way (work/school accounts) ME=$(az ad signed-in-user show --query id -o tsv 2>/dev/null) # If lookup fails, inspect the error and use your own user object ID from Entra. echo "ME=$ME" # must be a GUID before you continue az role assignment create --role "Key Vault Secrets Officer" \ --assignee-object-id "$ME" --assignee-principal-type User --scope "$VAULT_SCOPE" # role assignments take a minute to propagate sleep 60 az keyvault secret set --vault-name "$KV" --name "$SECRET_NAME" \ --value "Server=tcp:campux-sql.database.windows.net,1433;Database=products;Authentication=Active Directory Default;"
az keyvault secret show --vault-name "$KV" --name "$SECRET_NAME" --query id -o tsv prints the secret identifier, not its value. (If it returns Forbidden, inspect the assignment, identity and vault network rules. Propagation is one possible cause, not the only one. Retry after checking those facts.)The signed-in-user lookup can fail because of directory permissions or account context. Do not assume the cause from an empty value. You can copy your own object ID from the tenant’s user page instead. Stop if ME is empty; do not paste tokens into logs or shared files.
App Service with a managed identity
Many new/free subscriptions start with an App Service quota of 0 "Total VMs" in some regions, including eastus. If az appservice plan create fails with "Current Limit (Total VMs): 0", that is a subscription limit, not a mistake. The loop below creates the plan in the first region that has free-tier quota — the app can live in a different region from the vault and still works.
Commands inside $(...) save their output in a variable. Check the printed identifier or the checkpoint before continuing.
# Create the Free (F1) plan in the first region that has quota. for LOC in "$LOCATION" eastus2 westus2 centralus westus3 northeurope westeurope; do echo "Trying $LOC ..." if az appservice plan create --name "$PLAN" --resource-group "$RG" --sku F1 --is-linux --location "$LOC" 1>/dev/null 2>&1; then echo ">>> Plan created in $LOC"; break else echo " no F1 quota in $LOC — trying the next region" fi done # Auto-pick a currently-supported Node LTS. Azure retires versions over time, and # list-runtimes now returns objects, so query .config and swap the | for a : that --runtime wants. RT=$(az webapp list-runtimes --os-type linux --query "[?runtime=='Node'].config" -o tsv | grep -i lts | head -1 | tr '|' ':') echo "runtime: $RT" az webapp create --name "$APP" --resource-group "$RG" --plan "$PLAN" --runtime "$RT" az webapp identity assign --name "$APP" --resource-group "$RG" APP_PID=$(az webapp identity show --name "$APP" --resource-group "$RG" --query principalId -o tsv) echo "App identity object id: $APP_PID"
$APP_PID is a GUID. That GUID is the app's identity in Entra ID — the app’s identity was enabled; no client secret was generated. (If every region reports "no F1 quota," request an increase in the portal under Quotas → App Service, then re-run.)Grant the app least privilege
The app only needs to read secrets. Give it exactly that — Key Vault Secrets User — scoped to this one vault. Not the resource group, not the subscription, not Officer (which could write).
az role assignment create \ --role "Key Vault Secrets User" \ --assignee-object-id "$APP_PID" \ --assignee-principal-type ServicePrincipal \ --scope "$VAULT_SCOPE"
az role assignment list --scope "$VAULT_SCOPE" --query "[?principalId=='$APP_PID'].{role:roleDefinitionName, type:principalType}" -o table shows exactly one role: Key Vault Secrets User. Inspect this role’s current definition and other direct or inherited grants; this one row alone does not prove effective access. (We filter by principalId at the vault scope rather than --assignee "$APP_PID" on purpose: a brand-new managed identity takes a minute to appear in the Entra graph, and --assignee would error with "Cannot find … in graph database" until it does — even though the role is already granted.)Wire the secret in, deploy, and prove it
Point an app setting at the secret with a Key Vault reference. App Service resolves it using the managed identity before your code runs.
az webapp config appsettings set \ --name "$APP" --resource-group "$RG" \ --settings "DB_CONNECTION=@Microsoft.KeyVault(SecretUri=https://$KV.vault.azure.net/secrets/$SECRET_NAME)"
Create this small app locally. It reports only whether the training reference resolved; it never returns the stored value. Do not substitute a real database credential.
mkdir -p campux-identity-demo
cd campux-identity-demo
cat > package.json <<'JSON'
{"name":"campux-identity-demo","version":"1.0.0","scripts":{"start":"node server.js"}}
JSON
cat > server.js <<'JS'
const http = require('node:http');
http.createServer((req, res) => {
const value = process.env.DB_CONNECTION || '';
const resolved = value.length > 0 && !value.startsWith('@Microsoft.KeyVault(');
res.writeHead(resolved ? 200 : 503, {'Content-Type': 'application/json'});
res.end(JSON.stringify({referenceResolved: resolved}));
}).listen(process.env.PORT || 8080);
JS
# Git Bash on Windows has no zip; use this line instead:
# powershell.exe -NoProfile -Command "Compress-Archive -Force -Path server.js,package.json -DestinationPath app.zip"
zip app.zip server.js package.json
az webapp deploy --name "$APP" --resource-group "$RG" --src-path app.zip --type zip
az webapp restart --name "$APP" --resource-group "$RG"
curl --fail-with-body "https://$APP.azurewebsites.net"
Ask App Service what it thinks of the reference: az rest --method get --url "https://management.azure.com/subscriptions/$SUB/resourceGroups/$RG/providers/Microsoft.Web/sites/$APP/config/configreferences/appsettings?api-version=2022-03-01" --query "value[].properties.status" -o tsv. AccessToKeyVaultDenied long after the role was granted usually means App Service cached its first failed attempt. Run the Step 5 appsettings set line again, which makes it re-read the reference, then az webapp restart. When we ran this lab in October 2026 the reference stayed denied for over ten minutes until we did exactly that; the next request returned 200.
{"referenceResolved":true} with HTTP 200 proves the app received a resolved value; it does not print the value. A 503 with false means it is missing or unresolved. Check reference status, the identity object ID, its data role and vault networking. Allow for role propagation and restart before retrying; do not treat any error as proof of propagation.Tear it down
Delete everything, then purge the vault so its soft-deleted shell does not linger (Key Vault keeps deleted vaults recoverable for 90 days).
az group delete --name "$RG" --yes az keyvault purge --name "$KV" --location "$LOCATION" az group exists --name "$RG" # -> false az keyvault list-deleted --query "[?name=='$KV']" -o tsv # -> empty
false / empty — back to zero, lab resources removed; usage already incurred may still be billed, no reserved names.What you can now honestly claim
You configured an App Service with a system-assigned managed identity and a least-privilege Key Vault Secrets User role, and read a secret from Key Vault with no credentials in the code. Four things worth keeping: a managed identity has credentials Azure manages, while tokens and the host still need protection; RBAC grants Key Vault data access through Azure roles, and the creator is not automatically among them; least privilege is the narrowest role at the narrowest scope; and Key Vault references inject secrets as app settings with no code change. The same repo has a Bicep version that declares all of this as code.