Skip to content

3 Key Vault — Complete Reference


What it is

A managed secrets store. Instead of credentials sitting in config files, environment variables, or source control — they live in Key Vault, access-controlled, audited, and versioned. Your app pulls them at startup via the IConfiguration system as if they were normal config values.


Three types of objects

Secrets — plain text values. Connection strings, API keys, passwords, URLs. This is what you use. No cryptographic operations, just secure storage and retrieval.

Keys — cryptographic keys (RSA, EC, AES). The raw key material never leaves Key Vault. You send data to Key Vault, it performs the operation (encrypt, decrypt, sign, verify) inside the vault and returns the result. Used for signing UBL documents, encrypting sensitive data at rest, JWT signing keys.

Certificates — X.509 certificates with their private keys. Key Vault can generate, renew, and integrate with certificate authorities automatically. Used for TLS certificates, mutual TLS between services, code signing.

For your stack — Secrets only. If you ever need to digitally sign UBL invoices, the signing key would live as a Key, not a Secret.


Pricing tiers

Standard — secrets stored in software. Fine for connection strings, API keys, config values. Your use case.

Premium — secrets backed by a Hardware Security Module (HSM). Physical tamper-proof chip, raw key material never leaves hardware. Required for PCI-DSS, FIPS 140-2 compliance. Significantly more expensive. Not needed for PEPPOL.


One vault per app

Key Vault is shared infrastructure — one vault can serve multiple apps. But sharing forces a naming prefix convention to avoid collisions:

peppol--BlobStorage--AccountUrl
erp--BlobStorage--AccountUrl

This bleeds into your appsettings.json hierarchy and forces unnecessary nesting. The clean solution:

peppol-vault  → only PEPPOL secrets, no prefix needed
erp-vault     → only ERP secrets, no prefix needed

One vault per app. Secret names match your config hierarchy exactly. Pricing is per operation not per vault — creating a second vault costs nothing extra at your scale.


Secret naming convention

Key Vault uses -- (double dash) as the hierarchy separator. ASP.NET Core's Key Vault provider automatically translates -- to : when loading into IConfiguration:

Key Vault secret name       IConfiguration key
──────────────────────────────────────────────
BlobStorage--AccountUrl  →  BlobStorage:AccountUrl
BlobStorage--ContainerName → BlobStorage:ContainerName
RabbitMQ--Uri            →  RabbitMQ:Uri

In appsettings.json the same key is expressed as nested JSON:

{
  "BlobStorage": {
    "AccountUrl": "https://averblobstore.blob.core.windows.net"
  }
}

All three representations — Key Vault name, IConfiguration key, appsettings.json path — refer to the same value. Your DocumentStoreOptions POCO binding works identically regardless of where the value came from.


Integration with ASP.NET Core

Key Vault layers on top of appsettings.json via AddAzureKeyVault. Key Vault values override appsettings.json values for the same key:

builder.Configuration.AddAzureKeyVault(
    new Uri("https://peppol-vault.vault.azure.net/"),
    new DefaultAzureCredential()
);

Load order:

1. appsettings.json loads           base config
2. appsettings.{Environment}.json   environment overrides
3. AddAzureKeyVault loads           secrets override everything above

Your app doesn't know or care where a value came from. config["BlobStorage:AccountUrl"] works whether the value is in appsettings.json or Key Vault.

All secrets are pulled at startup in one batch — not lazy, not on-demand. If Key Vault is unreachable at startup, the app fails to start.


The secrets provider extension

Key Vault is one option for secrets. AWS Secrets Manager is another. To keep your composition root clean and avoid hardcoding the provider:

public static class ConfigurationBuilderExtensions
{
    public static IConfigurationBuilder AddSecretsProvider(
        this IConfigurationBuilder builder,
        IConfiguration config)
    {
        var provider = config["Secrets:Provider"];

        return provider switch
        {
            "AzureKeyVault" => builder.AddAzureKeyVault(
                new Uri(config["Secrets:KeyVaultUrl"]!),
                new DefaultAzureCredential()
            ),
            "None" => builder, // local dev — just appsettings
            _ => throw new InvalidOperationException($"Unknown secrets provider: {provider}")
        };
    }
}

Program.cs becomes one line:

builder.Configuration.AddSecretsProvider(builder.Configuration);

appsettings.json drives the decision:

{
  "Secrets": {
    "Provider": "AzureKeyVault",
    "KeyVaultUrl": "https://peppol-vault.vault.azure.net/"
  }
}

appsettings.Development.json overrides for local dev:

{
  "Secrets": {
    "Provider": "None"
  }
}

Locally — no Key Vault, just appsettings.json. Production — Key Vault layers on top. Zero if (isDevelopment) branching in code.


What lives in Key Vault vs appsettings.json

Rule of thumb: would you be embarrassed if this appeared in a public GitHub repo?

Key Vault — sensitive, never in source control
────────────────────────────────────────────────
BlobStorage--AccountUrl
ConnectionStrings--PostgreSQL
RabbitMQ--Uri
ExternalApi--ApiKey (Exact Online, WooCommerce, Orderchamp)

appsettings.json — non-sensitive, fine in source control
────────────────────────────────────────────────────────
Secrets:Provider
Secrets:KeyVaultUrl
DocumentStore:Provider
DocumentStore:ContainerName
Logging:LogLevel
AllowedHosts
Feature flags
Timeouts

AccountUrl is not sensitive by itself — it's a public endpoint. But it's good practice to keep it in Key Vault because it changes between environments and keeps all environment-specific values in one place.


Authentication

Key Vault uses the same DefaultAzureCredential pattern as everything else. But Key Vault has a critical split between control plane and data plane:

Control plane — managing the vault itself. Creating, deleting, configuring the vault resource. Covered by Owner/Contributor at the subscription or resource group level.

Control plane roles:

Contributor         manage vault resource
Owner               manage vault resource + assign roles

Data plane — reading and writing actual secrets. This is what your app does. NOT covered by Owner at subscription level. Must be assigned explicitly on the vault itself.

Data plane roles:

Key Vault Secrets User     → read secrets. Your app needs this.
Key Vault Secrets Officer  → read and write secrets. Your admin account needs this.
Key Vault Administrator    → full data plane access including keys and certificates.

This is the most common Key Vault gotcha — you have Owner at subscription, you expect to be able to read secrets, you get an RBAC error. Owner covers the control plane, not the data plane. Always assign Key Vault Secrets Officer to your user account and Key Vault Secrets User to your app's Managed Identity explicitly on the vault.

# Your user account — to manage secrets via portal/CLI
az role assignment create \
  --assignee your-object-id \
  --role "Key Vault Secrets Officer" \
  --scope "/subscriptions/.../vaults/peppol-vault"

# App's Managed Identity — to read secrets at runtime
az role assignment create \
  --assignee managed-identity-principal-id \
  --role "Key Vault Secrets User" \
  --scope "/subscriptions/.../vaults/peppol-vault"

Permission model — RBAC vs Access Policies

Two permission models exist. Always use RBAC:

Access Policies (old model) — Key Vault specific, separate from Azure RBAC. Inconsistent with how every other Azure service works. Being deprecated.

Azure RBAC (new model) — consistent with everything else. IAM tab, role assignments, same pattern as Blob Storage. Select this when creating a vault. If you see an existing vault on Access Policies, migrate it.


Key Vault references in App Settings

For Azure Functions and App Service — instead of pulling secrets via AddAzureKeyVault in code, you can reference Key Vault secrets directly in App Settings:

Name:  BlobStorageConnection
Value: @Microsoft.KeyVault(VaultName=peppol-vault;SecretName=BlobStorageConnection)

Azure resolves this at runtime before injecting the value into your app. Your app sees the actual connection string, never the reference syntax. No AddAzureKeyVault in code needed.

Requirements: the Function App or App Service must have a Managed Identity with Key Vault Secrets User role on the vault.

Use case: when you need a raw connection string available as an environment variable (like AzureWebJobsStorage for Functions runtime) rather than going through IConfiguration.


Secret versioning

Every secret has versions. When you update a secret, the old version is retained. You can reference a specific version or always get the latest:

Latest version   @Microsoft.KeyVault(VaultName=peppol-vault;SecretName=MySecret)
Specific version  @Microsoft.KeyVault(VaultName=peppol-vault;SecretName=MySecret;SecretVersion=abc123)

Always use latest version unless you have a specific reason to pin. Pinning means you have to update every reference when rotating — defeats the purpose.


Secret rotation

Key Vault supports expiration dates and Event Grid notifications for near-expiry secrets. Set expiration on externally issued API keys to force rotation:

az keyvault secret set \
  --vault-name peppol-vault \
  --name "ExactOnline--ApiKey" \
  --value "your-key" \
  --expires "2027-01-01T00:00:00Z"

Key Vault fires an event when the secret is within 30 days of expiry. Wire it to an Event Grid subscription to get notified before it causes an outage.

For connection strings and internal credentials you control — no expiration needed, rotate manually when needed.


Soft delete

Enabled by default on all new vaults, cannot be disabled. Deleted secrets go to a recycle bin for 90 days before permanent deletion.

Practical implication — if you delete a secret and try to create one with the same name, you get a conflict error. The soft-deleted version still exists.

Fix: purge the soft-deleted secret first:

az keyvault secret purge --vault-name peppol-vault --name MySecret

Or recover it if you deleted by mistake:

az keyvault secret recover --vault-name peppol-vault --name MySecret

Purge protection — if enabled, even you can't permanently delete a secret before the 90 day retention period. Leave this disabled for learning/dev. Enable in production for compliance-sensitive environments where accidental permanent deletion must be prevented.


The bootstrapping problem

Key Vault secures all your secrets. But to access Key Vault you need a credential. This is unavoidable.

Resolution:

On Azure (VM, Function App, App Service)
 Managed Identity opens Key Vault
 No credentials at all
 Problem fully solved

On non-Azure (third-party VM, your VPS)
 Service Principal (AZURE_CLIENT_ID/SECRET/TENANT) bootstraps Key Vault
 Everything else lives in Key Vault
 Three env vars is the irreducible minimum

Local development
 az login bootstraps Key Vault
 Or use Secrets:Provider=None and appsettings only
 No env vars needed

Gotchas

Control plane vs data plane — Owner at subscription does not grant secret read access. Assign Key Vault Secrets User explicitly on the vault. You will hit this.

Soft delete conflict — deleting and recreating a secret with the same name fails. Purge first.

Startup failure if unreachable — if Key Vault is down or misconfigured, your app fails to start. AddAzureKeyVault is eager, not lazy. Test your fallback path.

Secret name character restrictions — only alphanumeric and hyphens. No underscores, no dots, no forward slashes. Use -- for hierarchy, - for word separation.

App Settings cache — Function App and App Service cache Key Vault secret values. Changes to secrets in Key Vault don't take effect until the app is restarted or the cache expires (typically 24 hours). Restart the app after rotating a secret.

AddAzureKeyVault pulls everything at startup — if you have 200 secrets and your app only needs 5, it still pulls all 200. For large shared vaults this adds startup latency. One vault per app solves this — your vault only has the secrets your app needs.