Practical engineering guidance
Key Vault returns 403: which of the four causes is it?
An Owner who cannot read a secret, a firewall that looks open, and a private endpoint resolving to the wrong address all produce the same status code. A script that separates them.
Series: Azure architecture
An application cannot read a secret. The engineer who owns the subscription checks the portal, confirms they are an Owner, reads the secret successfully themselves, and reports that Key Vault is fine.
Both observations are correct, and they are unrelated. Key Vault separates the management plane from the data plane so completely that being an Owner of the resource tells you almost nothing about whether you can read what is inside it.
Four causes, one status code
A 403 from Key Vault has four common origins, and the authentication flow determines the order to check them:
- The firewall rejected the call before authorisation was considered. Public network access disabled, an IP rule that does not include the caller, or a request that did not arrive over the expected private link.
- The caller holds no data-plane permission. Under Azure RBAC,
OwnerandContributormanage the vault but do not read secrets. That is the design, not a bug. - The vault uses the other authorisation model than the one being configured. Access policies are ignored when RBAC is enabled, and role assignments are ignored when it is not, so a correct-looking grant in the wrong model has no effect at all.
- The assignment is correct and has not propagated. Documented as up to five minutes.
Cause 1 and cause 2 produce the same HTTP status with different messages. The message is therefore the most valuable thing in the failure, and the thing most often discarded before it reaches whoever is investigating.
The distinction that catches everyone
Under the RBAC model, data access requires a data-plane role: Key Vault Secrets User to
read secret values, Key Vault Secrets Officer to write them, Key Vault Reader for metadata
only, Key Vault Administrator for everything. Contributor and Key Vault Contributor grant
none of these.
Under the access policy model, the inverse is true and it is worse: anyone who can write to the vault resource — a Contributor — can add themselves to the access policy and then read every secret. That is Microsoft’s stated reason for recommending RBAC.
Note also that Key Vault control-plane API version 2026-02-01 and later creates new vaults with RBAC enabled by default unless told otherwise. Existing vaults are untouched, which means an estate can now easily contain both models, and a runbook that assumes one will be wrong half the time.
The script
Read-only. With -SecretName it attempts one data-plane read in order to classify a live
failure; that read is the same call the failing application makes, and it changes nothing.
<#
.SYNOPSIS
Diagnoses an Azure Key Vault access failure and reports which cause fits.
.DESCRIPTION
Read-only. Reports the vault's authorisation model, network configuration and
data-plane role assignments, and optionally attempts one secret read to classify a
live 403. Grants nothing and changes nothing.
Requires an authenticated Az session (Connect-AzAccount) with at least reader access
to the vault resource. Reading role assignments additionally requires permission to
read assignments at that scope.
.PARAMETER VaultName
The key vault to inspect.
.PARAMETER ResourceGroupName
The vault's resource group. Optional; resolved automatically when unambiguous.
.PARAMETER PrincipalId
Optional object ID of the identity that is failing, to filter role assignments to it.
.PARAMETER SecretName
Optional. Attempt to read this secret to classify a live failure. The value is never
displayed, returned or written to disk.
.EXAMPLE
.\Test-KeyVaultAccess.ps1 -VaultName 'kv-contoso-prod' -Verbose
.EXAMPLE
.\Test-KeyVaultAccess.ps1 -VaultName 'kv-contoso-prod' -SecretName 'api-key' -PrincipalId $objectId
.NOTES
Read-only: this script makes no changes and grants no access.
With -SecretName it retrieves a secret to test access; the value is discarded
immediately and never printed. Run it where that is acceptable.
#>
#requires -Version 7.0
#requires -Modules Az.KeyVault, Az.Resources
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string]$VaultName,
[string]$ResourceGroupName,
[string]$PrincipalId,
[string]$SecretName
)
$ErrorActionPreference = 'Stop'
$context = Get-AzContext
if ($null -eq $context) { throw 'Not connected. Run Connect-AzAccount first.' }
Write-Verbose "Subscription: $($context.Subscription.Name)"
$vaultParams = @{ VaultName = $VaultName }
if ($ResourceGroupName) { $vaultParams['ResourceGroupName'] = $ResourceGroupName }
$vault = Get-AzKeyVault @vaultParams -ErrorAction Stop
if (-not $vault) { throw "Key vault '$VaultName' not found in this subscription." }
$usesRbac = [bool]$vault.EnableRbacAuthorization
Write-Verbose "Authorisation model: $(if ($usesRbac) { 'Azure RBAC' } else { 'vault access policy' })"
# --- Data-plane grants, in whichever model the vault actually uses -----------------------
$grants = [System.Collections.Generic.List[object]]::new()
if ($usesRbac) {
$assignments = try {
Get-AzRoleAssignment -Scope $vault.ResourceId -ErrorAction Stop
}
catch {
Write-Warning "Could not read role assignments: $($_.Exception.Message)"
@()
}
foreach ($assignment in $assignments) {
if ($PrincipalId -and $assignment.ObjectId -ne $PrincipalId) { continue }
# Assignments inherited from the subscription or resource group are returned too;
# say where each one comes from rather than implying it was set on the vault.
$grants.Add([pscustomobject]@{
Model = 'RBAC'
Principal = $assignment.DisplayName
PrincipalId = $assignment.ObjectId
Role = $assignment.RoleDefinitionName
Scope = if ($assignment.Scope -eq $vault.ResourceId) { 'this vault' } else { "inherited: $($assignment.Scope)" }
GrantsData = $assignment.RoleDefinitionName -match '^Key Vault (Administrator|Secrets User|Secrets Officer|Crypto User|Crypto Officer|Crypto Service Encryption User|Certificates Officer|Certificate User|Reader)$'
})
}
$dataGrants = @($grants | Where-Object GrantsData)
if ($dataGrants.Count -eq 0) {
Write-Warning 'No Key Vault data-plane role is assigned at this scope. Owner and Contributor do not grant data access under the RBAC model.'
}
}
else {
foreach ($policy in $vault.AccessPolicies) {
if ($PrincipalId -and $policy.ObjectId -ne $PrincipalId) { continue }
$grants.Add([pscustomobject]@{
Model = 'AccessPolicy'
Principal = $policy.DisplayName
PrincipalId = $policy.ObjectId
Role = "secrets: $($policy.PermissionsToSecrets -join ',') | keys: $($policy.PermissionsToKeys -join ',') | certs: $($policy.PermissionsToCertificates -join ',')"
Scope = 'this vault'
GrantsData = ($policy.PermissionsToSecrets.Count + $policy.PermissionsToKeys.Count + $policy.PermissionsToCertificates.Count) -gt 0
})
}
Write-Verbose 'Vault uses access policies: any role assignment on this vault is ignored for data access.'
}
# --- Network position --------------------------------------------------------------------
$network = [pscustomobject]@{
PublicNetworkAccess = $vault.PublicNetworkAccess
DefaultAction = $vault.NetworkAcls.DefaultAction
Bypass = $vault.NetworkAcls.Bypass
IpRuleCount = @($vault.NetworkAcls.IpAddressRanges).Count
VirtualNetworkRules = @($vault.NetworkAcls.VirtualNetworkResourceIds).Count
}
$networkRestricted = ($vault.PublicNetworkAccess -eq 'Disabled') -or ($vault.NetworkAcls.DefaultAction -eq 'Deny')
if ($networkRestricted) {
Write-Verbose 'Vault restricts network access; a caller outside the allowed paths gets 403 before authorisation is evaluated.'
}
# --- Optional live probe -------------------------------------------------------------------
$probe = $null
if ($SecretName) {
try {
$null = Get-AzKeyVaultSecret -VaultName $VaultName -Name $SecretName -ErrorAction Stop
$probe = [pscustomobject]@{ Result = 'Succeeded'; Classification = 'This identity can read this secret from this network location.' }
Write-Verbose 'Data-plane read succeeded.'
}
catch {
$message = $_.Exception.Message
$classification =
if ($message -match 'ForbiddenByRbac|does not have secrets get permission|not authorized to perform action') {
'Authorisation: the identity lacks a data-plane permission in the model this vault uses.'
}
elseif ($message -match 'ForbiddenByFirewall|does not allow access|Public access is disabled|private link') {
'Network: the firewall or private-link configuration rejected the call before authorisation.'
}
elseif ($message -match 'NotFound|SecretNotFound|was not found') {
'The secret name does not exist in this vault, or it is soft-deleted and not recovered.'
}
else { 'Unclassified. Read the message below in full.' }
$probe = [pscustomobject]@{ Result = 'Failed'; Classification = $classification; Message = ($message -split "`r?`n")[0] }
Write-Warning $classification
}
}
# --- Verdict --------------------------------------------------------------------------------
$likely = [System.Collections.Generic.List[string]]::new()
if ($usesRbac -and @($grants | Where-Object GrantsData).Count -eq 0) {
$likely.Add('No data-plane role assignment (management-plane roles do not grant data access).')
}
if (-not $usesRbac -and @($grants | Where-Object GrantsData).Count -eq 0 -and $PrincipalId) {
$likely.Add('No access policy entry for this principal.')
}
if ($networkRestricted) {
$likely.Add('Network restrictions are in force: confirm the caller reaches the vault by an allowed path, and that DNS resolves to the private endpoint where one is used.')
}
if ($likely.Count -eq 0) {
$likely.Add('Configuration looks permissive for this principal. Consider propagation delay (up to five minutes), a different identity than expected at run time, or the caller''s network path.')
}
[pscustomobject]@{
Vault = $vault.VaultName
ResourceGroup = $vault.ResourceGroupName
Location = $vault.Location
CollectedUtc = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd HH:mm')
AuthorisationModel = if ($usesRbac) { 'Azure RBAC' } else { 'Vault access policy' }
SoftDeleteEnabled = $vault.EnableSoftDelete
PurgeProtection = $vault.EnablePurgeProtection
Network = $network
Grants = $grants
Probe = $probe
LikelyCauses = $likely
}
Using it during an incident
Run it with the -PrincipalId of the identity that is actually failing — the managed identity
or service principal, not your own account. Most of the wasted time in these incidents comes
from testing with a human account that has different access from the workload.
If LikelyCauses says the configuration looks permissive, the remaining candidates are, in
order: the application is authenticating as a different identity than you think; the role
assignment was made in the last few minutes; or the call is not reaching the vault by an
allowed network path. For private endpoints, the most common cause of a 403 is DNS — the client
resolves the vault’s public name to its public address and the request arrives from the
internet, where the firewall refuses it. Resolve the name from the client itself and compare.
Do not fix this by granting broader roles. Key Vault Secrets User on the specific vault is the correct grant for an application that reads secrets; Key Vault Administrator at subscription scope is how an incident becomes a finding in the next audit.
The adjacent problem: names that will not free up
Soft delete is on by default and cannot be disabled. A deleted vault, secret, key or certificate keeps its name reserved for the retention period — 7 to 90 days, default 90 — so recreating something with the same name fails until it is recovered or purged.
If purge protection is also on, nothing can purge it early: not an administrator, not Microsoft, not a support case. That is the point of the feature, and it is worth knowing before a deployment pipeline is blocked by it at an awkward moment. The script reports both flags for exactly this reason.
Verification and limits
The enableRbacAuthorization property and how to read it, the built-in data-plane role names,
the documented fact that management-plane roles such as Contributor do not grant data access,
the network properties (publicNetworkAccess, default action, bypass, IP and virtual network
rules), the documented authentication flow in which the firewall is evaluated before the
permission check, the ForbiddenByRbac error code, the up-to-five-minute propagation guidance,
the AuditEvent diagnostic category, soft delete and purge protection behaviour and the
2026-02-01 API version default change were checked against current Microsoft documentation on
20 September 2026.
The script was written for this article and statically analysed; it was not run against a production subscription.
Its principal limit is perspective: it inspects the vault from wherever you run it. The failing application’s network path — the one that determines whether a private endpoint is used, and what DNS returns — is not visible from your workstation. When the report says the configuration looks permissive, that is the next place to look, and the AuditEvent diagnostic logs will show whether the call reached the vault at all.
I found no Microsoft documentation enumerating Key Vault’s 404 semantics specifically, so the script’s not-found classification is based on the message text rather than a documented contract.
References
Reader feedback
Was this article useful?
No ratings yet. Be the first to rate this article.
