Key Vault access & recovery
RBAC data plane, purge protection, private vaults.
A bank's safe-deposit room takes two different keys, and they do not substitute for each other. One opens the room itself: the street door, the lights, the alarm wiring, whether the place is open at all. The other opens a single box inside, the one holding your passport and the deed to your house. Hand someone the room key and they still cannot get into your box. Azure Key Vault works the same way. One set of permissions controls the vault as an object you own, and a completely separate set controls the secrets, keys, and certificates locked inside it. Microsoft calls these the control plane (you will also hear it called the management plane) and the data plane, and mixing them up is the mistake that either breaks your apps or quietly leaves them wide open. This lesson hardens both, then adds the two settings that decide whether a compromised admin can permanently destroy what is inside, and the log that catches them trying.
Two keys, two planes
Every action against a vault runs on one of those two planes, and they line up with the two keys. Control-plane actions treat the vault as a thing you own: create it, move it, change its firewall, read its properties. That is the room key, and Azure authorizes it with the same role-based access control (RBAC, permission sets bundled into named roles like Owner, Contributor, and Reader) you already use for virtual machines and storage. Data-plane actions reach the contents: read a secret's value, sign with a key, import a certificate. That is the box key, authorized on its own separate track. That separation is the whole point of the design.
A vault runs its data plane in one of two modes. The old way is access policies: a guest list taped to the vault's own door that hands out coarse permission sets per operation type. Give someone Get on secrets and they can read every secret in the vault, not the three they actually need. The modern way is the Azure RBAC data plane, the same building-wide access system that governs every other resource, evaluated by the same engine with granular built-in roles (new key vaults now default to it, though plenty of older ones still run on access policies). Key Vault Secrets User reads secret values. A separate Key Vault Secrets Officer creates and rotates them, which your app does not get. Key Vault Crypto User signs, wraps, and decrypts with keys. Key Vault Administrator has full data-plane control. Prefer RBAC. It lines up with everything else, it turns up in the same access reviews, it works with just-in-time elevation, and it lets you narrow a grant down to a single secret instead of the whole room. One trap catches teams: a Contributor on the vault can reconfigure it all day but cannot read one secret value, and cannot hand itself a data-plane role either, because Contributor has no permission to write role assignments. There is a sharper reason to leave access policies behind. On a vault still using them, anyone with Contributor can write themselves onto that guest list and read every secret, because editing the list is a control-plane action Contributor is allowed to do. Switch the vault to RBAC and that door shuts: the guest list is no longer read, and Contributor cannot grant itself an RBAC role.
Grant a workload only what it needs
A managed identity works like a staff keycard the building reissues every morning on its own, so there is never an old copy floating around for someone to clone. Azure creates the identity for a workload, rotates its credential, and hands the code a short-lived token whenever it asks, with no password stored anywhere. That identity lives in Microsoft Entra ID (Microsoft's cloud directory of users, groups, and app logins, the service that used to be called Azure Active Directory, or Azure AD). Here you switch a vault to RBAC, then give one app's managed identity read-only access to secrets, scoped to that single vault and nothing else.
# Switch this vault's data plane from access policies to RBAC.az keyvault update -g rg-app -n app-kv --enable-rbac-authorization true# Grant the app's managed identity READ-ONLY on secrets, scoped to THIS vault only.VAULT_ID=$(az keyvault show -g rg-app -n app-kv --query id -o tsv)az role assignment create \--assignee-object-id "$APP_MI_OBJECT_ID" \--assignee-principal-type ServicePrincipal \--role "Key Vault Secrets User" \--scope "$VAULT_ID"
{"id": "/subscriptions/.../resourceGroups/rg-app/providers/Microsoft.KeyVault/vaults/app-kv","properties": {"enableRbacAuthorization": true,"vaultUri": "https://app-kv.vault.azure.net/"}}{"id": "/subscriptions/.../providers/Microsoft.Authorization/roleAssignments/5d2e9c81-7a44-4f0b-8e2d-6b1a3c9f7e05","principalId": "8f3c1e5a-2b90-4d17-9a44-1c0f7b6d2e21","principalType": "ServicePrincipal","roleDefinitionId": "/subscriptions/.../providers/Microsoft.Authorization/roleDefinitions/4633458b-17de-408a-b874-0445c86b69e6","scope": "/subscriptions/.../resourceGroups/rg-app/providers/Microsoft.KeyVault/vaults/app-kv","type": "Microsoft.Authorization/roleAssignments"}
Now prove you did not over-grant. List every assignment on the vault and read the roles and principal types straight back.
az role assignment list --scope "$VAULT_ID" \--query "[].{role:roleDefinitionName, type:principalType}" -o table
Role Type---------------------- ---------------Key Vault Secrets User ServicePrincipal
Three details in that grant matter more than they look. The --assignee-principal-type ServicePrincipal flag tells Azure not to go ask Microsoft Graph (the directory's query API) what kind of principal this is. A managed identity is created asynchronously, so in the seconds after you make one, Graph may not know it exists yet, and without the flag the assignment can fail with a principal-not-found error while replication catches up. Second, the scope is the vault's own resource ID, never the resource group. A role assignment on a resource group flows downhill to every vault, storage account, and database beneath it, which is how a well-meaning grant of Key Vault access turns into subscription-wide reach. Third, RBAC is eventually consistent, meaning a change becomes true everywhere after a short delay rather than the instant you make it. Give a new grant a few minutes to take hold, sometimes closer to ten. A freshly deployed app that gets Forbidden on its very first secret read is usually waiting on propagation, not misconfigured.
az keyvault update --enable-rbac-authorization true does not blend the two models. It hands the data plane to RBAC in one move, and every existing access-policy grant stops being read the moment the change commits. If you have not already created the matching role assignments (and waited the few minutes for them to propagate), every app and pipeline reading from that vault starts returning Forbidden at the same time. Create and verify the RBAC assignments first, flip the switch second, and rehearse the whole thing on a throwaway vault before you touch production.Borrow the master key, do not carry it
A standing Key Vault Administrator is a copy of the master key sitting in someone's pocket all day, every day. One phished session and the attacker holds it too. A well-run building handles its master key differently: it lives in a logged lockbox, and the manager on shift signs it out for that shift, with a reason, then drops it back. Azure's version is Privileged Identity Management (PIM, a system that grants privileged roles on demand instead of leaving them switched on). PIM makes a role eligible rather than active. Day to day the person holds no live privilege. When they genuinely need it, they activate for a bounded window, with a justification, multi-factor authentication (MFA, a second proof of identity on top of the password), and optional approval, and the grant expires on its own. PIM rides on Microsoft Entra ID P2, the premium identity licensing tier, so confirm you have it before you build a process around this.
There is no az pim command. You drive PIM through Azure Resource Manager (ARM, the control API that sits behind every Azure resource) with az rest, which signs a raw REST call for you. Making someone eligible is a roleEligibilityScheduleRequest, an admin action you do once. Activating is a roleAssignmentScheduleRequest with requestType set to SelfActivate, which the eligible person runs for themselves when they need the role. Read the activation body closely. Its fields are the whole accountability story: who, why, and for how long. Keep the eligible set tiny, one operator here, a small on-call group in production.
{"properties": {"principalId": "$MY_OID","roleDefinitionId": "$KVADMIN","requestType": "SelfActivate","justification": "rotate breached signing key INC-4821","scheduleInfo": {"startDateTime": "$NOW","expiration": { "type": "AfterDuration", "duration": "PT8H" }}}}
# Built-in "Key Vault Administrator" role definition id (a well-known GUID):KVADMIN="/subscriptions/$SUB/providers/Microsoft.Authorization/roleDefinitions/00482a5a-887f-4fb3-b363-3b7fe8e74483"NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)BASE="https://management.azure.com${VAULT_ID}/providers/Microsoft.Authorization"# (a) One-time, done by an admin: make the operator ELIGIBLE (not active) for Key Vault Administrator on this vault.az rest --method put \--uri "${BASE}/roleEligibilityScheduleRequests/$(uuidgen)?api-version=2020-10-01" \--body "$(jq -nc --arg p "$MY_OID" --arg r "$KVADMIN" --arg t "$NOW" '{properties:{principalId:$p,roleDefinitionId:$r,requestType:"AdminAssign",scheduleInfo:{startDateTime:$t,expiration:{type:"NoExpiration"}}}}')"# (b) When they actually need it, the operator ACTIVATES just-in-time for 8h, reason attached (fully audited).az rest --method put \--uri "${BASE}/roleAssignmentScheduleRequests/$(uuidgen)?api-version=2020-10-01" \--body @activate-kv-admin.json
{"properties": {"principalId": "b7e2c9a1-4f83-4d20-9e17-2a5c8d1f6b04","roleDefinitionId": ".../roleDefinitions/00482a5a-887f-4fb3-b363-3b7fe8e74483","requestType": "AdminAssign","status": "Provisioned"}}{"properties": {"principalId": "b7e2c9a1-4f83-4d20-9e17-2a5c8d1f6b04","requestType": "SelfActivate","justification": "rotate breached signing key INC-4821","scheduleInfo": { "expiration": { "type": "AfterDuration", "duration": "PT8H" } },"status": "Provisioned"}}
Every activation is fully audited. PIM keeps a resource-audit record of who elevated, when, and the exact justification string they typed, and the new role assignment also lands in the Azure Activity Log (Azure's running record of every control-plane change). An eight-hour admin window that closes on its own and leaves a named record behind is a very different risk from a permanent grant nobody remembers approving. This is the sharpest reason yet to prefer RBAC over access policies: access policies have no notion of eligible versus active, so just-in-time admin cannot exist on them at all.
Delete is not destroy
Two settings decide whether someone deleting your keys is a bad afternoon or the end of the company. Soft-delete works like a document you drop in the office recycling bin: the bin does not get emptied for a set number of days, so anything tossed in can be fished back out. Soft-delete is on for every vault, it cannot be turned off, and it holds deleted secrets, keys, and certificates in a recoverable state for a window you set when you create the vault, anywhere from 7 to 90 days, 90 by default. Soft-delete on its own still lets a sufficiently privileged caller reach into the bin and run the shredder early, an operation called purge. Purge protection is the second setting, and it welds the shredder shut. Once purge protection is on, nobody, not an Owner, not the person who deleted the object, can purge anything before its retention window fully runs out.
That one setting turns wiping the vault from an instant, unrecoverable attack into one you have days to notice and undo. If your keys wrap encrypted data, so that losing the key loses the data with it, purge protection is the line between a scare and a disaster. Turn it on, then walk through what a real deletion looks like.
# Turn on purge protection. The recovery window was fixed at creation (90 days here);# purge protection itself CANNOT be turned off once it is on.az keyvault update -g rg-app -n app-kv --enable-purge-protection true# Someone deletes a production secret. It is not gone. It is soft-deleted.az keyvault secret delete --vault-name app-kv -n db-conn-string# It is still there and recoverable, with a scheduled auto-purge date.az keyvault secret list-deleted --vault-name app-kv \--query "[].{name:name, scheduledPurge:scheduledPurgeDate}" -o table# An attacker with data-plane admin tries to erase it for good. Purge protection refuses.az keyvault secret purge --vault-name app-kv -n db-conn-string# You bring it back, live, with one command.az keyvault secret recover --vault-name app-kv -n db-conn-string
{ "properties": { "enablePurgeProtection": true, "enableSoftDelete": true, "softDeleteRetentionInDays": 90 } }Name ScheduledPurge-------------- -------------------------db-conn-string 2026-10-20T09:14:03+00:00(Forbidden) The operation is not allowed because purge protection is enabled for this vault.{ "attributes": { "enabled": true }, "id": "https://app-kv.vault.azure.net/secrets/db-conn-string/9f2c8b1e4a7d4f6b8c2a1e5d3f9b0c7a" }
Two operational facts ride along with recovery. First, a soft-deleted vault holds onto its name for the whole retention window. Key Vault names are global DNS names (app-kv.vault.azure.net), so until the old vault is recovered or purged, nobody anywhere can stand up a new app-kv. That bites in blue/green rebuilds, where you tear an environment down and bring an identically named one back up. Recovering the old vault instead of recreating it has its own catch: the recovery brings back your keys and secrets, but not the vault's role assignments, which were dropped when it was deleted, so you reapply those by hand. Second, the data plane is rate-limited. Key Vault caps secret reads at roughly 4,000 every 10 seconds per vault, per region, with creating or importing far lower (a shared budget of 300) and a subscription-wide ceiling at five times the per-vault number. A fleet that reads its secrets in a tight startup loop trips 429 Too Many Requests the moment enough instances boot at once. Cache the value and lean on Key Vault references instead of polling, which the Secrets, references and rotation lesson covers.
Shut the public door, watch the private one
A vault reachable from the public internet is one stolen token away from being emptied, no matter how careful your RBAC is. The token does not care which network it is presented from. So take the public door off its hinges. Set --public-network-access Disabled and reach the vault only through a Private Endpoint, a private IP (internet protocol) address for the vault that lives inside your own virtual network (VNet, your walled-off slice of the Azure network). A credential lifted off a developer's laptop then has nowhere to connect, because the vault has no address on the open internet.
# Take the vault off the public internet; the only path in is now the Private Endpoint in your VNet.az keyvault update -g rg-app -n app-kv --public-network-access Disabled# Stream every data-plane operation to Log Analytics for audit and detection.az monitor diagnostic-settings create -n kv-audit \--resource "$VAULT_ID" \--workspace "$LAW_ID" \--logs '[{"category":"AuditEvent","enabled":true}]'
{ "properties": { "publicNetworkAccess": "Disabled" } }{ "id": ".../app-kv/providers/microsoft.insights/diagnosticSettings/kv-audit", "name": "kv-audit" }
Closing the public door removes one hole. It does not tell you when something goes wrong on the inside. That is the job of the diagnostic setting above: the AuditEvent category streams every data-plane call to a Log Analytics workspace (Azure's log store and query engine), giving you a record to keep and a signal to alarm on. The signal that matters most is one identity reading many different secrets in a short span. A healthy app reads the same two or three secrets it always needs. An attacker wearing that app's stolen identity reads all of them, fast, because they do not know which one is worth anything. That shape is short to write in KQL (Kusto Query Language, the query language Log Analytics runs on).
// One identity reading many DISTINCT secrets in 5 minutes = the exfiltration fingerprint.AzureDiagnostics| where ResourceProvider == "MICROSOFT.KEYVAULT"| where OperationName == "SecretGet" and ResultType == "Success"| summarize distinctSecrets = dcount(id_s), reads = count()by CallerIPAddress,caller = identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g,bin(TimeGenerated, 5m)| where distinctSecrets > 25| order by reads desc
CallerIPAddress caller distinctSecrets reads---------------- -------------------------------------- --------------- -----20.51.12.7 8f3c1e5a-2b90-4d17-9a44-1c0f7b6d2e21 142 210
--enable-rbac-authorization true set on a vault, a user who holds only the Contributor role runs az keyvault secret show. What happens?app-kv with a 90-day window. An attacker holding Key Vault Administrator deletes a secret, then immediately runs az keyvault secret purge on it. What is the result?--scope to the resource group rg-app instead of the vault's resource ID. rg-app also holds three other vaults and a storage account. What is the effect?The hardening in this lesson protects the vault and the copies of secrets inside it. It says nothing about the cryptography those keys perform on your data. The next lesson, CMK (customer-managed keys), Managed HSM and at-rest, goes inside the boxes: how a single key in this vault encrypts your storage, disks, and databases through envelope encryption (a big data key does the bulk work, and this vault key only wraps that data key), why disabling that one key can freeze an entire service while the ciphertext sits there intact, and when a shared software-backed vault stops being enough and you move to a single-tenant Managed HSM (hardware security module, tamper-resistant hardware that holds keys so the private material never leaves it), validated to FIPS 140-3 Level 3 (a US government bar for cryptographic hardware).
Try this
Run az keyvault update -g rg-app -n app-kv --enable-rbac-authorization true on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.
Takeaway
The trap worth remembering here: flipping to RBAC voids every access policy the instant it lands. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.