PSI Key Vaults

Canonical reference for which vault to use and how to get access to it. The command-level walkthroughs for wiring secrets into an App Service live in deploy-to-azure §7 and §8; this page is the decision, the permission models, and the traps.


TL;DR — the four things that catch people

  1. There are two vaults and they use different permission models. ps-certificates-kv uses access policies; psi-finance-kv uses RBAC. A grant that works on one does nothing on the other.
  2. Assigning an RBAC role on ps-certificates-kv has no effect. Key Vault Secrets User looks right, appears in the portal, and grants nothing — the vault doesn’t read RBAC. This is the single most common access ticket.
  3. Access policies are vault-wide. There is no way to scope one to a single secret. Read on one secret is read on all of them. That is why bank credentials got their own vault.
  4. Both vaults deny by default. From a workstation you must be on-site. Off-site you get ForbiddenByFirewall no matter what permissions you hold.

The two vaults

ps-certificates-kvpsi-finance-kv
Use it forApp secrets, connection strings, client secrets, the wildcard certificateFinance credentials with a blast radius that must stay small
Permission modelAccess policiesRBAC
Can scope to one secret?❌ No — vault-wide only✅ Yes
Resource groupPS-RG-01PS-RG-01
Soft delete / purge protection90 days / enabled90 days / enabled
Private endpointps-certificates-kv-pe10.160.140.23psi-finance-kv-pe10.160.140.32
Reachable fromOffice egress IP, PS-WebApps and ps-flexfunc subnets, private endpointIdentical
AuditAuditEvent → Log AnalyticsAuditEvent → Log Analytics

Which one do I use?

Default to ps-certificates-kv. It is the primary vault for app deployments and where the wildcard certificate lives.

Use psi-finance-kv only when a secret must be readable by a narrow set of principals and it would be unacceptable for everyone already in the shared vault to read it — money movement, banking, payroll. If you’re reaching for it for an ordinary app secret, you don’t need it.

Why a second vault exists at all

ps-certificates-kv holds ~73 secrets and grants access to roughly thirty principals. Because access policies cannot be scoped below the vault, adding one more reader to that vault means adding a reader of every app’s database password and client secret. For an app secret that’s an acceptable trade. For a bank’s SFTP credentials it isn’t — hence a separate, RBAC-based vault where a grant can be pinned to a single secret.


Granting access

ps-certificates-kv — access policies

Grant with set-policy against an object ID. Not a role assignment.

# a person or a group
az keyvault set-policy --name ps-certificates-kv \
  --object-id <objectId> --secret-permissions get list
 
# an app's managed identity (the usual case) — read-only is enough
az keyvault set-policy --name ps-certificates-kv \
  --object-id $(az webapp identity show -n <app> -g PS-WEBAPPS --query principalId -o tsv) \
  --secret-permissions get

Notes that matter:

  • Use a security group, not a Microsoft 365 group. Access policies resolve group membership from the sign-in token. A mail-enabled M365/Unified group is not a reliable principal here; a plain security group is.
  • set is rare and deliberate. Most principals get get (+ list only if they genuinely enumerate). Writing secrets is an admin action.
  • Binding the wildcard certificate needs certificates get in addition to secrets — a caller with secrets-only permissions fails at the certificate step.

psi-finance-kv — RBAC

Grant with a role assignment. set-policy will not work.

# read a specific secret only — the point of this vault
az role assignment create --assignee-object-id <objectId> --assignee-principal-type Group \
  --role "Key Vault Secrets User" \
  --scope ".../vaults/psi-finance-kv/secrets/<secret-name>"
 
# manage secrets in the vault (create, rotate)
az role assignment create --assignee-object-id <objectId> --assignee-principal-type User \
  --role "Key Vault Secrets Officer" \
  --scope ".../vaults/psi-finance-kv"
RoleGives
Key Vault Secrets UserRead secret values
Key Vault Secrets OfficerCreate, update, rotate, delete secrets
Owner / ContributorNothing on the data plane. Management-plane roles do not grant secret access under RBAC

That last row is the RBAC equivalent of the access-policy trap: being subscription Owner lets you manage the vault and still not read a secret.


Using a secret from an app

App Service — Key Vault references (the default)

Preferred for anything hosted on App Service. No SDK, no code, no secret in the repo.

  1. Enable the app’s system-assigned managed identity.
  2. Grant that identity read (set-policy … --secret-permissions get, or Key Vault Secrets User for an RBAC vault).
  3. Set an app setting whose value is a reference:
@Microsoft.KeyVault(VaultName=ps-certificates-kv;SecretName=myapp--db-password)

The runtime resolves it at startup and the app reads an ordinary configuration value.

Step 2 is necessary but not sufficient on Linux — set vnetRouteAllEnabled: true

ps-certificates-kv is defaultAction: Deny with virtual-network rules for PS-WebApps and ps-flexfunc. Microsoft requires that Linux apps reaching a network-restricted vault route all outbound traffic through the VNet — the default RFC1918-only routing is not enough. Every PSI web app is Linux, so this applies to all of them:

az webapp config set -n <app> -g PS-WEBAPPS --vnet-route-all-enabled true

Without it the reference fails with a message that reads like a permissions problem, which sends you to re-check an access policy that is already correct:

AccessToKeyVaultDenied — site was denied access to Key Vault reference's vault

Check vnetRouteAllEnabled before you touch the access policy.

Reading the vault audit log: the public 403 is expected, not the failure

A successful Key Vault reference produces two log entries — a Forbidden from the app’s public outbound IP, immediately followed by an OK from its private IP. Microsoft documents this as by design. Verified on ps-bom-coverage 2026-08-12 with no ipRule in place:

22:03:17  52.162.107.11    Forbidden   ← public attempt, expected
22:03:24  10.160.150.250   OK          ← VNet path succeeds

Only a Forbidden with no following success is a real failure. Treating the public 403 as the fault is a costly misread — it produced a full afternoon of wrong diagnoses on psi-azure-admin#8, including a false conclusion that the fleet was running on cached secrets.

Do not add App Service outbound IPs to the vault firewall

Microsoft is explicit: “Vaults shouldn’t depend on the app’s public outbound IP addresses because the origin IP address of the secret request could be different. Instead, the vault should be configured to accept traffic from a virtual network that the app uses.” The address set also shifts when a plan scales or moves stamp, so it resurfaces later as an unexplained outage — and it widens a vault holding every PSI secret to shared Azure stamp addresses used by other tenants. This was tried on 2026-08-12 and reverted; the vnet rules plus vnetRouteAllEnabled are the supported path and need no per-app maintenance.

Verifying a new app's reference

A Resolved status can be a cached value whose re-fetch is failing, so for a new app confirm against the vault itself rather than the status field. Force a re-resolve with any app-setting change, then check:

az rest --method get --url "https://management.azure.com/subscriptions/<sub>/resourceGroups/PS-WEBAPPS/providers/Microsoft.Web/sites/<app>/config/configreferences/appsettings?api-version=2022-03-01"

And confirm against the vault itself, which is the only real proof:

AzureDiagnostics
| where ResourceProvider == 'MICROSOFT.KEYVAULT'
| where id_s has '<secret-name>'
| project TimeGenerated, CallerIPAddress, ResultSignature, ResultDescription

Diagnosed on ps-bom-coverage (psi-azure-admin#8).

Deployment slots have their own managed identity

A slot’s MI is a different principal from the production slot’s and needs its own access policy. prgjsmes-prod/slots/staging (oid=bb2cc61f-587c-4589-a8c9-9c02c973095e) has none, and fails every secret read on this vault continuously. Same shape as the slot-MI SQL grant issue.

The app setting name is the config key — the secret name is only a pointer

With App Service references, AzureAd__ClientSecret as the setting name produces AzureAd:ClientSecret in IConfiguration (__:). The secret can be called anything. This is different from the SDK provider below, and confusing the two is a common source of “the value is there but my config binding is empty.”

.NET — the SDK, when references don’t apply

Use when the consumer isn’t an App Service, or needs to re-read a secret at runtime.

Azure.Identity                     1.21.0
Azure.Security.KeyVault.Secrets     4.11.0
  • Hosted in AzureDefaultAzureCredential picks up the managed identity with no configuration.
  • A desktop app run by a person → an interactive credential, plus an app registration with the delegated https://vault.azure.net/user_impersonation scope. PSI has user consent disabled tenant-wide, so that scope needs admin consent or every user dead-ends in the approval flow. See webapp-compliance-standard.
  • Fetch by name with get. Don’t grant list unless the app genuinely enumerates — an app that can’t list can’t leak the inventory.

If you use the configuration provider (AddAzureKeyVault) rather than fetching by name, the secret name becomes the config key and -- maps to :. That is the opposite direction from App Service references.


Naming

<app>--<secret>, lower-kebab. The -- namespaces the secret to its owning app:

erp-migration-api--db-password
bom-explorer-web--azure-openai-api-key
ach-upload--sftp-private-key

Some older secrets use PascalCase (ProjectExplorer--AADClientId). Match the convention for new work; don’t rename existing ones — app settings reference them by name and a rename is a breaking change.

Secret values cap at 25 KB, which comfortably holds a PEM private key (~2 KB).


Network reality

Both vaults are defaultAction: Deny with AzureServices bypass.

Both vaults now carry the same posture — psi-finance-kv was brought to parity on 2026-08-07.

CallerBoth vaults
Workstation, on-site✅ office egress IP
Workstation, off-site or VPNForbiddenByFirewall
App Service (VNet-integrated)PS-WebApps subnet
Flex Consumption functionps-flexfunc subnet
Anything resolving the private endpoint from the VNet✅ — PE traffic bypasses the firewall entirely
ps-cicd-runner, ps-argo-etl❌ on PS-SERVERS, no rule

Three consequences worth internalising:

  • Reaching the vault is not reading from it. A private endpoint and a subnet rule only get the request to the door. The data plane is still gated per-principal — access policies on one vault, RBAC on the other. A web app on PS-WebApps with no grant gets Forbidden, and on psi-finance-kv that includes subscription Owners.
  • Forbidden vs ForbiddenByFirewall tells you which layer failed. ForbiddenByFirewall means the network refused you. Plain Forbidden means the network let you through and permissions refused you. Read the error before changing anything.
  • IP rules take public addresses only. You cannot whitelist an internal subnet (10.x, 192.9.x) — that traffic leaves via the office egress address, which is what’s actually on the list.

Private endpoints need records in both zones

Each vault PE has an A record in the Azure private zone privatelink.vaultcore.azure.net (managed automatically by the endpoint’s DNS zone group) and a matching record in the AD-integrated primary zone of the same name on the domain controllers. The Azure zone serves the deploy runner; the DC zone serves every other VNet and VPN client. This is the same two-zone rule as web apps — see dns-standards.


Audit

Both vaults ship AuditEvent to Log Analytics. Every secret read is recorded with the caller identity.

AzureDiagnostics
| where ResourceProvider == "MICROSOFT.KEYVAULT"
| where OperationName == "SecretGet"
| project TimeGenerated, Resource, identity_claim_upn_s, id_s, ResultSignature
| order by TimeGenerated desc

Failure → cause

SymptomCause
Can open the vault in the portal, secrets blade is emptyRBAC role assigned on ps-certificates-kv, which uses access policies. The role grants control-plane read and nothing else. Add an access policy
Forbidden from a subscription Owner on psi-finance-kvOwner is management-plane. RBAC vaults need Key Vault Secrets User or Officer on the data plane
ForbiddenByFirewallOff-site, or a caller on a subnet that isn’t permitted. See the network table
Group-based access silently doesn’t workThe group is a Microsoft 365 / Unified group. Use a security group
App setting shows the literal @Microsoft.KeyVault(...) stringThe reference didn’t resolve — usually the app’s managed identity has no read grant, or the vault isn’t reachable from the app’s subnet
Config binding empty though the secret existsSetting name vs secret name confusion — see the note under App Service references
Certificate binding fails, secrets work fineCaller lacks certificates get. Secrets and certificates are separate permission sets
az keyvault set-policy succeeds but changes nothingYou ran it against an RBAC vault. It’s a no-op there