Assign a role to a managed identity and prove access works with no secret stored anywhere.
Managed identity and least privilege, in one task
A managed identity is a credential Azure creates and rotates for you — there is no secret to store, and therefore none to leak. You give it to the app, grant it a role, and the app authenticates as itself. The second half of the lesson is the role: not "can touch the vault", but exactly Key Vault Secrets User — read secret contents, nothing more. No write, no keys, no certificates. That pairing — an identity with no password, holding the narrowest role that does the job — is what "secure by default" actually looks like in practice, and it is the thing an interviewer is checking when they ask how your app gets its secrets.
The secret nobody can paste is the secret nobody can leak.
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. On your own subscription you are Owner. Run it all in Cloud Shell (Bash); az, zip, and curl are already there.
App Service F1 (Free), a Key Vault (about $0.03 per 10,000 operations), one secret. A fraction of a cent, and the teardown at the end removes all of it.
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.
# 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-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're 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's 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.
# your user object id — the normal way (work/school accounts) ME=$(az ad signed-in-user show --query id -o tsv 2>/dev/null) # personal accounts (Gmail/Outlook) return nothing above — read the oid from your token instead: if [ -z "$ME" ]; then PAYLOAD=$(az account get-access-token --query accessToken -o tsv | cut -d. -f2 | tr '_-' '/+') case $(( ${#PAYLOAD} % 4 )) in 2) PAYLOAD="$PAYLOAD==";; 3) PAYLOAD="$PAYLOAD=";; esac ME=$(printf '%s' "$PAYLOAD" | base64 -d 2>/dev/null | grep -o '"oid":"[^"]*"' | cut -d'"' -f4) fi 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 value -o tsv prints the string. (If it returns Forbidden, the role is still propagating — wait 60s and re-run the secret set line. Not a mistake in your commands.)az ad signed-in-user show asks Entra "who am I?" — but a personal Microsoft account (a Gmail or Outlook address on a free subscription) is a guest in its own directory and that call comes back empty. Rather than chase a mangled guest username, the fallback reads the oid (object id) claim already inside your sign-in token: same GUID, works for every account type. If echo "$ME" ever shows blank, stop — the role grant can't work without it.
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's 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.
# 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 — no password anywhere. (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. Read of secret contents only — no write, no keys, no certificates. (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 "[email protected](SecretUri=https://$KV.vault.azure.net/secrets/$SECRET_NAME)"
Get the tiny zero-dependency app and deploy it, then restart once — the restart forces App Service to re-fetch the reference with the role you just set, and warms the free-tier container so your first request isn't a cold-start 502.
git clone https://github.com/kloudcaptain/campux-labs.git
cd campux-labs/lab-a-rbac-managed-identity/app
# Git Bash on Windows often has no 'zip' — fall back to PowerShell's Compress-Archive
zip app.zip server.js package.json 2>/dev/null \
|| powershell -NoProfile -Command "Compress-Archive -Path server.js,package.json -DestinationPath app.zip -Force"
az webapp deploy --name "$APP" --resource-group "$RG" --src-path app.zip --type zip
az webapp restart --name "$APP" --resource-group "$RG"
sleep 45
for i in 1 2 3 4 5; do
echo "--- attempt $i ---"
curl -sS "https://$APP.azurewebsites.net" && break
sleep 20
done
@Microsoft.KeyVault(...), the role is propagating — wait a minute, az webapp restart, and curl again.)Tear it down
Delete everything, then purge the vault so its soft-deleted shell doesn't 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, no surprise bill, 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 is a credential Azure manages so there is nothing to leak; 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.