Every article about Azure Key Vault tells you the same thing: your app reads configuration through the same interfaces it always did, and the fact that the values now live in a vault is invisible to your code. It sounds almost too clean. I wanted to watch it happen rather than take it on faith.
So I did the whole thing as a POC in two deliberate phases. First, a one-page ASP.NET Core app that reads a database connection string and some app settings straight out of appsettings.json and renders them on a page. Get that boring and working. Then, move every one of those values into Key Vault and change as little application code as I possibly could, and see if the page still renders the same.
Spoiler: the entire code change is one block in Program.cs, and not a single line of the page or the data access changes. This is the walkthrough, including the one Azure error that stopped me cold for two minutes.
What we are building
A single Razor Pages screen with two tables side by side. On the left, rows from a SQL Server Products table, proving the connection string works. On the right, a handful of app settings shown as key/value pairs. Nothing fancy, because the point is not the page, it is where the values come from.
The stack:
| Piece | Choice |
|---|---|
| Framework | ASP.NET Core Razor Pages, .NET 10 |
| Data access | Microsoft.Data.SqlClient, raw ADO.NET |
| Database | SQL Server Express, local |
| Secrets | Azure Key Vault, RBAC mode |
I used raw ADO.NET instead of EF Core on purpose. With no migrations and no DbContext in the way, the connection string's role is impossible to miss, and that is exactly the thing I am going to move into the vault later.
Pre-requisites
- .NET 10 SDK.
- A local SQL Server instance you can create a database on. I used SQL Server Express, but LocalDB or a container works the same.
- An Azure subscription. A standard vault has no hourly charge, and secret operations bill at about $0.03 per 10,000, so a POC like this costs nothing worth measuring.
- Azure CLI, logged in with
az login. This matters more than it looks, because the app authenticates as whoever that command signed in as.
Phase 1: the boring version that works
Scaffold
dotnet new webapp -n KeyVaultDemo -o . --framework net10.0
dotnet add package Microsoft.Data.SqlClient
Configuration in the clear
Everything starts life in appsettings.json. A connection string, and an AppSettings section with a few values. Two of these settings, Environment and ApiSecretMessage, are worded so I can tell later whether the value on screen came from the file or the vault.
{
"ConnectionStrings": {
"DefaultConnection": "Server=localhost\\MSSQLSERVER01;Database=KeyVaultDemoDb;Trusted_Connection=True;TrustServerCertificate=True;Encrypt=True;"
},
"AppSettings": {
"ApplicationName": "Key Vault Demo POC",
"Environment": "Local (appsettings.json)",
"FeatureFlagEnabled": "true",
"ApiSecretMessage": "This value currently lives in appsettings.json"
}
}
I used Windows authentication (Trusted_Connection=True) against a local SQL Server Express instance, so there is no password to babysit during Phase 1.
The settings type, bound the normal way
A plain POCO for the settings section. Nothing about this class knows or cares where its values originate, which is the whole reason the swap later is painless.
namespace KeyVaultDemo;
public class AppSettings
{
public string ApplicationName { get; set; } = string.Empty;
public string Environment { get; set; } = string.Empty;
public string FeatureFlagEnabled { get; set; } = string.Empty;
public string ApiSecretMessage { get; set; } = string.Empty;
}
Wire it up in Program.cs with the standard options binding:
builder.Services.AddRazorPages();
builder.Services.Configure<KeyVaultDemo.AppSettings>(
builder.Configuration.GetSection("AppSettings"));
The page
The page model opens a connection, reads the Products table, and exposes the settings via IOptions<AppSettings>. The two things to notice are GetConnectionString("DefaultConnection") and IOptions<AppSettings>. Remember them, because they never change for the rest of this post.
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Options;
namespace KeyVaultDemo.Pages;
public record Product(int Id, string Name, string Category, decimal Price);
public class IndexModel : PageModel
{
private readonly IConfiguration _configuration;
private readonly AppSettings _appSettings;
public IndexModel(
IConfiguration configuration,
IOptions<AppSettings> appSettings)
{
_configuration = configuration;
_appSettings = appSettings.Value;
}
public List<Product> Products { get; private set; } = new();
public AppSettings Settings => _appSettings;
public string? ConnectionError { get; private set; }
public void OnGet()
{
var connectionString = _configuration.GetConnectionString("DefaultConnection");
try
{
using var connection = new SqlConnection(connectionString);
connection.Open();
using var command = new SqlCommand(
"SELECT Id, Name, Category, Price FROM dbo.Products ORDER BY Id;",
connection);
using var reader = command.ExecuteReader();
while (reader.Read())
{
Products.Add(new Product(
reader.GetInt32(0), reader.GetString(1),
reader.GetString(2), reader.GetDecimal(3)));
}
}
catch (Exception ex)
{
ConnectionError = ex.Message;
}
}
}
I wrapped the read in a try/catch that surfaces the error onto the page instead of throwing. That is not how I would write it for real, but during a config swap it pays for itself, because a wrong connection string shows up as a red box on the page telling you exactly what broke instead of a stack trace.
And the view, Pages/Index.cshtml. Bootstrap comes with the webapp template, so the two tables sit side by side for free. This file never changes again after this point, which is worth remembering when you get to Phase 2.
@page
@model IndexModel
<h1>@Model.Settings.ApplicationName</h1>
@if (Model.ConnectionError is not null)
{
<div class="alert alert-danger">
<strong>Connection failed:</strong> @Model.ConnectionError
</div>
}
<div class="row">
<div class="col-md-6">
<h2>Products</h2>
<table class="table table-striped">
<thead>
<tr>
<th>Id</th>
<th>Name</th>
<th>Category</th>
<th>Price</th>
</tr>
</thead>
<tbody>
@foreach (var p in Model.Products)
{
<tr>
<td>@p.Id</td>
<td>@p.Name</td>
<td>@p.Category</td>
<td>@p.Price.ToString("C")</td>
</tr>
}
</tbody>
</table>
</div>
<div class="col-md-6">
<h2>App settings</h2>
<table class="table table-striped">
<tbody>
<tr>
<td>ApplicationName</td>
<td>@Model.Settings.ApplicationName</td>
</tr>
<tr>
<td>Environment</td>
<td>@Model.Settings.Environment</td>
</tr>
<tr>
<td>FeatureFlagEnabled</td>
<td>@Model.Settings.FeatureFlagEnabled</td>
</tr>
<tr>
<td>ApiSecretMessage</td>
<td>@Model.Settings.ApiSecretMessage</td>
</tr>
</tbody>
</table>
</div>
</div>
The database
A small script creates the database, a Products table, and seeds five rows.
IF DB_ID('KeyVaultDemoDb') IS NULL CREATE DATABASE KeyVaultDemoDb;
GO
USE KeyVaultDemoDb;
GO
CREATE TABLE dbo.Products (
Id INT IDENTITY(1,1) PRIMARY KEY,
Name NVARCHAR(100) NOT NULL,
Category NVARCHAR(50) NOT NULL,
Price DECIMAL(10, 2) NOT NULL
);
GO
INSERT INTO dbo.Products (Name, Category, Price) VALUES
(N'Wireless Mouse', N'Electronics', 25.99),
(N'Mechanical Keyboard', N'Electronics', 79.50),
(N'Coffee Mug', N'Kitchen', 12.00),
(N'Notebook', N'Office', 4.75),
(N'Desk Lamp', N'Office', 33.20);
GO
dotnet run, open the page, and there it is: five products on the left, four settings on the right, the Environment row reading "Local (appsettings.json)". Phase 1 done. Now the interesting part.
Phase 2: move it all into Key Vault
Before any commands, the mental model, because Key Vault has three separate ideas that are easy to blur together.
- The vault is a container for secrets with its own URL, like
https://myvault.vault.azure.net/. - You, on the control plane, need permission to create secrets. That is the role Key Vault Secrets Officer.
- The app, on the data plane, needs permission to read secrets at runtime.
For a local POC the third point has a neat shortcut. The app authenticates with DefaultAzureCredential, which quietly picks up whoever is logged in through az login. That is me. And the Secrets Officer role I give myself in a moment already includes read. So the app, running as my own Azure identity, is covered with no extra role assignment.
One warning about that shortcut, because it is the thing most likely to waste your evening. DefaultAzureCredential tries a chain of credential sources in a fixed order, and Visual Studio and VS Code sit ahead of the Azure CLI in that chain. If you are signed into Visual Studio with a different account than az login, the app authenticates as the Visual Studio account, gets a Forbidden on the vault, and the error tells you nothing about which identity it used. az account show tells you who the CLI is, and the tenant and account it prints have to be the ones you granted the role to.
I used RBAC for permissions rather than the older access-policy model, because RBAC is the current Azure default and what you would reach for in production.
Create the vault
Vault names are globally unique across all of Azure, 3 to 24 characters, letters, digits and hyphens only, starting with a letter. I am in PowerShell, so variables use the $ syntax.
$rg = "rg-keyvault-demo"
$location = "eastus"
$vault = "kv-demo-unique-0809"
az group create --name $rg --location $location
az keyvault create --name $vault --resource-group $rg --location $location `
--enable-rbac-authorization true
And here is where I got stopped:
(MissingSubscriptionRegistration) The subscription is not registered
to use namespace 'Microsoft.KeyVault'.
This one looks scary and is nothing. Azure only turns on the resource providers you actually use, and on a fresh subscription Microsoft.KeyVault is simply off. The resource group succeeded a second earlier because Microsoft.Resources is always on. You register the provider once, ever, per subscription:
az provider register --namespace Microsoft.KeyVault
az provider show --namespace Microsoft.KeyVault --query registrationState -o tsv
Wait for that second command to print Registered, which took about a minute, then the keyvault create goes through.
Give yourself permission to write secrets
Under RBAC, being the vault's creator grants you exactly zero data-plane access. You have to assign yourself a role explicitly, which surprises people the first time.
$me = az ad signed-in-user show --query id -o tsv
$vaultId = az keyvault show --name $vault --query id -o tsv
az role assignment create --assignee $me --role "Key Vault Secrets Officer" --scope $vaultId
One thing to know: role assignments take a couple of minutes to propagate. If your next command fails with a Forbidden, you did nothing wrong, you were just fast. Wait and retry.
Put the secrets in
Now the naming trick that makes the whole thing work. Key Vault secret names allow only letters, digits, and hyphens, so you cannot use the : that .NET configuration uses to express hierarchy. The Key Vault configuration provider bridges this by mapping a double dash -- to :. So the secret AppSettings--ApiSecretMessage becomes the config key AppSettings:ApiSecretMessage, which is exactly what the AppSettings POCO already binds to.
az keyvault secret set --vault-name $vault --name "ConnectionStrings--DefaultConnection" `
--value "Server=localhost\MSSQLSERVER01;Database=KeyVaultDemoDb;Trusted_Connection=True;TrustServerCertificate=True;Encrypt=True;"
az keyvault secret set --vault-name $vault --name "AppSettings--ApplicationName" --value "Key Vault Demo POC"
az keyvault secret set --vault-name $vault --name "AppSettings--Environment" --value "Azure Key Vault"
az keyvault secret set --vault-name $vault --name "AppSettings--FeatureFlagEnabled" --value "true"
az keyvault secret set --vault-name $vault --name "AppSettings--ApiSecretMessage" --value "This value now comes from Azure Key Vault"
Notice Environment and ApiSecretMessage are worded differently from the appsettings.json versions. That is the tell. When the page comes up reading "Azure Key Vault", I know the value on screen was served out of the vault and not off disk.
Take the secrets out of the file
This is the step that makes it a migration rather than a demo. Delete the ConnectionStrings and AppSettings sections from appsettings.json entirely. What is left is a file with no secrets in it at all:
{
"Logging": {
"LogLevel": {
"Default": "Information"
}
},
"AllowedHosts": "*",
"KeyVault": {
"Uri": "https://kv-demo-unique-0809.vault.azure.net/"
}
}
The vault's URL is not a secret, so it stays behind as a plain pointer. Everything else is gone, which means that if the page still renders after this, there is no ambiguity about where the values came from. There is nowhere else for them to come from.
The only code change in the entire post
Two packages:
dotnet add package Azure.Identity
dotnet add package Azure.Extensions.AspNetCore.Configuration.Secrets
Azure.Identity gives you DefaultAzureCredential. The other package is the adapter that plugs Key Vault in as an ASP.NET Core configuration source.
Here is Program.cs in full, because the whole point is how little of it is about Azure:
using Azure.Identity;
var builder = WebApplication.CreateBuilder(args);
// Register Key Vault as a configuration source.
// Everything below this line is untouched from Phase 1.
var keyVaultUri = builder.Configuration["KeyVault:Uri"];
if (!string.IsNullOrWhiteSpace(keyVaultUri))
{
builder.Configuration.AddAzureKeyVault(
new Uri(keyVaultUri),
new DefaultAzureCredential());
}
builder.Services.AddRazorPages();
builder.Services.Configure<KeyVaultDemo.AppSettings>(
builder.Configuration.GetSection("AppSettings"));
var app = builder.Build();
app.UseStaticFiles();
app.MapRazorPages();
app.Run();
That is it. Scroll back up to the page model. GetConnectionString("DefaultConnection") is unchanged. IOptions<AppSettings> is unchanged. The AppSettings class is unchanged. The Razor view is unchanged. The page has no idea Azure exists.
Two mechanics carry the whole thing:
- The
--to:mapping turns each vault secret name back into the exact config key the app already asked for.AppSettings--ApiSecretMessagein the vault isAppSettings:ApiSecretMessagein configuration, which is the property the POCO was already bound to in Phase 1. - Configuration is layered. Sources are registered in order and later ones win, so
AddAzureKeyVaultsitting after the defaults means vault values would beatappsettings.jsonif both had a value for the same key. Here they do not, because I emptied the file first. Worth knowing anyway, because it is what lets you keep non-secret defaults in the file and let the vault fill in only the sensitive keys on top.
builder.Configuration["KeyVault:Uri"] is read before the vault is registered, so that one value has to come from appsettings.json or an environment variable. It cannot live in the vault, for the obvious reason.
Three things this call does that are easy to miss
- It reads every secret in the vault, not the ones you asked for. There is no filter in the call above. It enumerates the whole vault and flattens all of it into configuration. Share a vault between two apps and each one loads the other's secrets into memory. Use a vault per app, or pass a
KeyVaultSecretManagerthat filters by prefix. - Secrets are fetched once, at startup. There is no polling by default. Change a secret in the portal and the running app keeps the old value until you restart it.
AzureKeyVaultConfigurationOptions.ReloadIntervalturns on polling if you want it, at the cost of an operation charge per interval per instance. - A vault outage is a startup failure. Because this runs during
CreateBuilder, an unreachable vault or a missing role assignment throws before the app ever starts serving. That is usually the behaviour you want, but it does mean the vault is now on the critical path for boot.
Proof
dotnet run. The page comes up looking the same as it did in Phase 1, except the Environment row now reads Azure Key Vault and the secret message shows the new text.
What makes that convincing is the state of appsettings.json. There is no connection string in it and no AppSettings section, so the five products on the left could only have been fetched using a connection string that came out of the vault. Same for every row on the right. Delete the secrets from the vault and the page has nothing to fall back on.
And none of the code that reads those values was touched to make it work.
Cleanup
One command removes almost everything, and then one more, because Key Vault has a twist.
Check what is actually there first, so you delete the thing you think you are deleting:
az group show --name $rg --query "{name:name, location:location}" -o table
Delete the resource group
Everything created here lives inside rg-keyvault-demo, including the role assignment, which was scoped to the vault. A resource group is a hard container, so deleting it takes all of that with it. --yes skips the confirmation the CLI would otherwise ask for, since this cannot be undone.
az group delete --name $rg --yes
Purge the soft-deleted vault
This is the part that catches people. Soft delete is on by default and cannot be turned off, so deleting the vault does not actually remove it. Azure parks it in a recoverable state for 90 days, and the vault name stays reserved that whole time. Try to recreate a vault with the same name tomorrow and you get a conflict, not a fresh vault. That is the right behaviour when the secrets are real. For a throwaway POC you want the name back.
az keyvault list-deleted --query "[].name" -o table
az keyvault purge --name $vault
purge is permanent, with no recovery afterwards, which is exactly what you want here. It only works if purge protection was never enabled on the vault. If it was, you wait out the retention period, and that is the feature working rather than a problem to solve.
Verify
az group exists --name $rg
That prints false when the delete has finished, and the vault should no longer show up in az keyvault list-deleted. If it prints true, the delete is still running server-side. It takes a minute or two, so just check again.
Two things stay behind on purpose:
- The
Microsoft.KeyVaultprovider registration. That is a subscription-level flag, it costs nothing, and you would only turn it back on next time. - The
KeyVault:Uripointer inappsettings.json. It now points at a vault that does not exist, and since the file no longer holds the connection string either, the app has nothing to fall back on. It will fail on startup until you recreate the vault or put the original sections back. That is the expected end state once the POC is done, not something broken.
What actually clicked for me
Reading about this and doing it land differently.
- Configuration is a stack of sources, and Key Vault is just one more layer on it. Once that clicks, the "no code change" claim stops being magic and becomes obvious. You did not integrate a vault, you registered one more config source and deleted the one it replaced.
- RBAC gives the vault's own creator no data access by default. Creating a vault and reading its secrets are genuinely different permissions, and the separation is deliberate. An admin who can manage infrastructure does not automatically get to read production credentials.
DefaultAzureCredentialriding onaz loginis the reason local development stays pleasant. No secret needed to bootstrap the secrets, no chicken-and-egg. In production you swap that same credential for a managed identity and the app code still does not change.
The natural next step is to deploy this to App Service with a system-assigned managed identity and grant that identity the Secrets User role, so the app reads the vault in the cloud with no credentials stored anywhere. The block in Program.cs stays exactly as it is. Maybe that is the next post.
Revision checklist
- Key Vault plugs into ASP.NET Core as a configuration source, not a client library you call. The app keeps reading
IConfigurationandIOptions<T>. --in a secret name becomes:in a config key.AppSettings--ApiSecretMessagebinds to the same POCO property as before.- Configuration sources are layered and later ones win. Registering
AddAzureKeyVaultafter the defaults is what lets a vault value beat anappsettings.jsonvalue for the same key. - Migrating means emptying the file, not just filling the vault. Leave the old sections in
appsettings.jsonand you still have plaintext secrets on disk, whatever the vault holds. - The vault URI itself cannot live in the vault, so it stays in
appsettings.jsonas a plain pointer. - Control plane and data plane are separate. Creating a vault grants zero access to its secrets under RBAC.
- Key Vault Secrets Officer is read and write on secrets, Key Vault Secrets User is read only. Give apps the second one.
- Role assignments take a couple of minutes to propagate. A
Forbiddenright after assigning usually means you were quick, not wrong. MissingSubscriptionRegistrationmeans the resource provider is off on that subscription.az provider register --namespace Microsoft.KeyVault, once ever.DefaultAzureCredentialchecks Visual Studio and VS Code before the Azure CLI. Mismatched accounts show up as an unexplainedForbidden.- The provider loads every secret in the vault at startup, once, with no reload unless you set
ReloadInterval. - Soft delete is mandatory. A deleted vault holds its name for 90 days unless you purge it.
What this does not cover
- Managed identity, which is what replaces
az loginthe moment this leaves my machine. - Secret rotation and versioning. Every secret here has exactly one version and nothing ever changes it.
- Referencing vault secrets from App Service app settings directly, which skips the config provider entirely and is sometimes the better answer.
- Private endpoints and firewall rules. This vault is open to the public endpoint and protected only by RBAC.
- Certificates and keys. Key Vault stores all three, and this post only touches secrets.