You create an app registration, give it access to Azure Key Vault, and your integration still fails with a 403 when it tries to read a secret. The confusing part is that some things work: the app can authenticate, the vault exists, and it may even list secret names. Then the actual get secret value call fails.
That usually means you granted the wrong Key Vault role.
Authentication is not vault access
Azure splits this into two separate checks.
Microsoft Entra ID answers: who is this app? If the client ID, tenant ID, or client secret is wrong, token acquisition fails. That is authentication.
Key Vault RBAC answers: what can this app read from this vault? A valid token can still get rejected by a specific vault. That is authorization.
The distinction matters when you handle errors:
| Status | What it usually means | What to check |
|---|---|---|
| 401 | Entra rejected the principal | Tenant ID, client ID, client secret, expired credential |
| 403 | Principal exists but lacks access | Role assignment on this vault |
| 404 | Vault or secret was not found | Vault URL, secret name, deleted resource |
| 429 | Key Vault throttled the request | Retry policy and request volume |
Do not collapse 401 and 403 into a generic “access denied” path. If you do, someone will rotate a client secret that was never broken, while the missing RBAC assignment stays missing.
Wire keeps this distinction when it reads Azure Key Vault secrets for account logins, so a missing vault role does not look like a rejected Entra credential.
The role name is the trap
Azure has a role called Key Vault Reader. It sounds like the right role for an app that needs to read from Key Vault.
It is not.
Key Vault Reader allows metadata access. An app can see information about keys, certificates, and secrets, but it cannot read secret values. This is why the failure feels inconsistent: listing may work, but fetching the value fails.
For secret values, use:
Key Vault Secrets User
Assign it per vault. Access to one vault does not imply access to another.
Here is the Azure CLI version I usually use because it makes the scope explicit:
APP_ID="00000000-0000-0000-0000-000000000000"
RESOURCE_GROUP="rg-prod"
VAULT_NAME="prod-login-vault"
SP_OBJECT_ID=$(az ad sp show \
--id "$APP_ID" \
--query id \
-o tsv)
VAULT_ID=$(az keyvault show \
--name "$VAULT_NAME" \
--resource-group "$RESOURCE_GROUP" \
--query id \
-o tsv)
az role assignment create \
--assignee-object-id "$SP_OBJECT_ID" \
--assignee-principal-type ServicePrincipal \
--role "Key Vault Secrets User" \
--scope "$VAULT_ID"
Then test the exact operation your app needs:
az keyvault secret show \
--vault-name "$VAULT_NAME" \
--name "github-login" \
--query value \
-o tsv
With the wrong role, you will see a failure like this:
(Forbidden) The user, group or application does not have secrets get permission on key vault
That message is the important part: secrets get permission. Listing secrets is not enough.
Treat the vault list as a security boundary
There is another practical edge: a service principal cannot ask the Key Vault data plane, “which vaults can I access?”
To enumerate vaults, you need Azure management-plane APIs and broader subscription permissions, often Reader at subscription or resource group scope. That is a much larger grant than “read these specific secrets.”
For most integrations, it is safer to configure the vault URLs explicitly and treat that list as the scope. Then validate every secret reference against it before making a Key Vault request.
A simple check looks like this:
const allowedVaultHosts = new Set([
"prod-login-vault.vault.azure.net",
"shared-automation-vault.vault.azure.net"
]);
export function assertAllowedVault(secretUrl: string) {
const url = new URL(secretUrl);
const host = url.hostname.toLowerCase();
if (!allowedVaultHosts.has(host)) {
throw new Error(`Vault is not in the configured allowlist: ${host}`);
}
return url;
}
Run this check on every resolution, not only when someone saves the configuration. Stored references can be edited, migrated, or generated by code. The read path should still enforce the boundary.
Because the configured vault URLs define scope, Wire rejects Key Vault references outside that list instead of trying to discover or follow them.
Store multi-field credentials deliberately
An Azure Key Vault secret is one opaque string. It is not a password-manager item with separate username and password fields.
If your app needs multiple fields, JSON is usually the least surprising option:
az keyvault secret set \
--vault-name "prod-login-vault" \
--name "github-login" \
--value '{"username":"bot@example.com","password":"correct-horse-battery-staple"}'
Then parse it after reading the value:
type LoginSecret = {
username: string;
password: string;
};
function parseLoginSecret(value: string): LoginSecret {
const parsed = JSON.parse(value);
if (typeof parsed.username !== "string" || typeof parsed.password !== "string") {
throw new Error("Secret must contain string username and password fields");
}
return parsed;
}
You can put a username in a tag if you need to, but do not put passwords in tags. Key Vault tags are metadata. People and services with metadata access can see them without being allowed to read secret values.
Let versionless references handle rotation
Key Vault keeps versions for each secret. If you fetch a secret without a version, Azure returns the latest version:
https://prod-login-vault.vault.azure.net/secrets/github-login
If you include a version, you pin the value:
https://prod-login-vault.vault.azure.net/secrets/github-login/635d6f...
For application logins and other runtime credentials, versionless references are usually what you want. Rotate the secret in Key Vault, and the next read gets the new value. Existing sessions keep whatever they already used until they expire.
Also turn on Key Vault diagnostic logs. Once logs flow into Log Analytics, you can query reads from your own tenant:
AzureDiagnostics
| where ResourceProvider == "MICROSOFT.KEYVAULT"
| where OperationName in ("SecretGet", "SecretList")
| project TimeGenerated, Resource, OperationName, identity_claim_appid_g, ResultType
| order by TimeGenerated desc
Before you ship an integration, test three cases on purpose: wrong client secret, missing Key Vault Secrets User on one vault, and a valid read from each configured vault. If those produce different errors in your app, your future incident response will be much less annoying.
Top comments (0)