Secrets management in .NET is less about a single API and more about a lifecycle: a connection string or API key needs to exist somewhere while you develop locally, somewhere different and much more locked down once the app is deployed, and it needs to be replaceable the moment it leaks or expires. This guide walks through that lifecycle end to end for ASP.NET Core and other .NET apps: the Secret Manager tool for local development, why environment variables are a common but risky middle step, the Azure Key Vault configuration provider for production, managed identities and DefaultAzureCredential so the app never holds a credential just to fetch its other credentials, workload identity for Kubernetes, and how rotation and CI secret scanning close the loop. It assumes you currently have secrets scattered across appsettings.json files and environment variables and want a coherent, incremental way out.

Why Secrets Leak#

Most secret leaks are not exotic attacks; they are ordinary developer convenience that outlives its original context. A connection string gets typed straight into appsettings.json to unblock a demo, the commit ships, and even after someone "removes" it in a later commit, the value sits permanently in git history where any clone can recover it. A .env file with real production values gets copied from one teammate to another because it is faster than re-provisioning, and eventually lands in a public fork. Screenshots, support tickets and log aggregators capture secrets that were only ever meant to flash by in a terminal. None of this requires a sophisticated attacker: public repositories are scanned continuously by both defenders and attackers, and a leaked cloud credential is typically used within minutes of becoming public. The fix is architectural, not procedural — make the secure path the easy path, so nobody has to remember to be careful.

How .NET Configuration Layers Secrets#

IConfiguration in .NET is a merge of ordered providers, where a key set by a later provider overrides the same key set by an earlier one. WebApplication.CreateBuilder wires up a default order that already anticipates this lifecycle:

  1. appsettings.json — safe defaults, no secrets.
  2. appsettings.{Environment}.json — environment-specific but still not secret.
  3. User Secrets — Development environment only.
  4. Environment variables.
  5. Command-line arguments.
  6. Anything you add explicitly, such as Azure Key Vault, in the order you add it.

Because later providers win, a value from Key Vault or an environment variable transparently overrides the same key in appsettings.json without any conditional code — the application reads builder.Configuration["ConnectionStrings:Default"] the same way regardless of which provider ultimately supplied it. That uniformity is what makes it realistic to keep the exact same code path across a developer's laptop, a CI pipeline and production.

Getting Started: User Secrets for Local Development#

The Secret Manager tool keeps development-time secrets out of the project tree entirely, so they can never be accidentally committed:

Bash
dotnet user-secrets init
dotnet user-secrets set "Sql:ConnectionString" "Server=.;Database=Dev;Trusted_Connection=True;"
dotnet user-secrets list

init adds a UserSecretsId to the project file and creates no other project changes:

XML
<PropertyGroup>
  <UserSecretsId>a1b2c3d4-0000-1111-2222-333344445555</UserSecretsId>
</PropertyGroup>

The values live in a JSON file under the current user's profile directory, keyed by that GUID, completely outside the repository. Reading them back needs no special code, because User Secrets is just another configuration provider:

C#
var connectionString = builder.Configuration["Sql:ConnectionString"];

Secret Manager does not encrypt the file — it is a developer convenience, not a vault, and Microsoft's own documentation is explicit that it should never be treated as a trusted store. Its job is narrower and still valuable: making sure a secret needed only for local development never has a reason to appear in source control.

Environment Variables: Convenient but Risky#

ASP.NET Core's environment variable provider maps a double underscore to the colon that separates configuration sections, so ConnectionStrings__Default becomes the same key as ConnectionStrings:Default in appsettings.json:

Bash
export ConnectionStrings__Default="Server=prod-sql;Database=App;User Id=svc;Password=REDACTED;"

This is convenient and widely supported by every hosting platform, which is exactly why it gets overused as a permanent home for secrets rather than a transport. Environment variables are visible to anything that can inspect the process — docker inspect, /proc/<pid>/environ on Linux, a crash dump, or a CI log step that accidentally echoes the environment for debugging — and they are inherited by every child process the application spawns. Using them to inject a secret that a real secret store issued at deploy time is fine and common; using them as the permanent, version-controlled storage location for that secret, via a committed .env file, is the same mistake as a hardcoded connection string wearing a different hat.

Azure Key Vault Configuration Provider#

For Azure-hosted production apps, Azure Key Vault becomes a configuration provider, so secrets stop being something the app deploys with and become something it fetches at startup. This needs two packages: Azure.Extensions.AspNetCore.Configuration.Secrets and Azure.Identity.

C#
var builder = WebApplication.CreateBuilder(args);

var keyVaultName = builder.Configuration["KeyVaultName"];
var keyVaultUri = new Uri($"https://{keyVaultName}.vault.azure.net/");
builder.Configuration.AddAzureKeyVault(keyVaultUri, new DefaultAzureCredential());

var app = builder.Build();

Every secret in the vault is loaded as a configuration key. Because Key Vault secret names cannot contain a colon, a double dash stands in for the section separator: a secret named ConnectionStrings--Default in the vault surfaces as the configuration key ConnectionStrings:Default in the app, matching whatever hierarchical key your code already reads. By default, configuration is loaded once at startup and not refreshed — set ReloadInterval if you want the app to pick up a new secret version without a redeploy:

C#
builder.Configuration.AddAzureKeyVault(keyVaultUri, new DefaultAzureCredential(),
    new AzureKeyVaultConfigurationOptions { ReloadInterval = TimeSpan.FromMinutes(15) });

Managed Identities and DefaultAzureCredential#

The call above still needs a credential to authenticate to Key Vault — the point of a managed identity is that the credential is never something you store. Azure assigns the running resource (an App Service, a container app, a VM) its own Microsoft Entra ID identity, and DefaultAzureCredential is built to use it transparently:

C#
// The exact same line works locally (falling back to Azure CLI or Visual
// Studio sign-in) and in Azure (using the resource's managed identity),
// because DefaultAzureCredential tries each credential source in order
// and uses whichever one succeeds first.
builder.Configuration.AddAzureKeyVault(keyVaultUri, new DefaultAzureCredential());

For a user-assigned managed identity — one identity shared across several resources — tell the credential which client id to use, either via the AZURE_CLIENT_ID environment variable or explicitly:

C#
var credential = new DefaultAzureCredential(new DefaultAzureCredentialOptions
{
    ManagedIdentityClientId = builder.Configuration["ManagedIdentity:ClientId"],
});

DefaultAzureCredential is deliberately forgiving during development, trying several credential sources until one works. For code that only ever runs as a deployed Azure resource, ManagedIdentityCredential is a more explicit, single-path alternative worth switching to once the app leaves local development, since it removes the fallback probing entirely:

C#
var credential = new ManagedIdentityCredential();

Workload Identity in Kubernetes#

A Kubernetes pod has no managed identity of its own, and a Kubernetes Secret object is only base64-encoded, not encrypted, so copying a service principal's client secret into one just relocates the leak risk rather than removing it. Workload identity solves this the same way managed identity does for Azure compute: a pod's own Kubernetes service account token is federated with Microsoft Entra ID and exchanged for a short-lived access token, with no long-lived secret stored in the cluster at all.

YAML
apiVersion: v1
kind: ServiceAccount
metadata:
  name: app-identity
  annotations:
    azure.workload.identity/client-id: "00001111-aaaa-2222-bbbb-3333cccc4444"
  labels:
    azure.workload.identity/use: "true"

Once the workload identity webhook has labeled and annotated the pod this way, it injects the client id, tenant id and a path to a projected, auto-rotated federated token into the pod's environment. No application code changes: DefaultAzureCredential (and, if you construct it directly, WorkloadIdentityCredential) picks those values up the same way it picks up a VM's managed identity.

C#
// Unchanged from the Azure-hosted example — DefaultAzureCredential finds
// the federated token that the workload identity webhook injected.
builder.Configuration.AddAzureKeyVault(keyVaultUri, new DefaultAzureCredential());

This is the same pattern covered for container orchestration more broadly in Running .NET on Kubernetes: identity and configuration should travel with the platform, not with a secret baked into an image or a manifest.

Rotation Strategies#

A secret that is never rotated is a secret an attacker only has to steal once. Azure Key Vault versions every secret rather than overwriting it in place, so publishing a new version does not immediately invalidate the old one — combined with a ReloadInterval, this lets an app pick up a new database password or API key on its own polling schedule instead of requiring a coordinated redeploy at the exact rotation instant. Key Vault secrets can also carry an expiration date, and the configuration provider skips disabled or expired secrets automatically, which turns a forgotten rotation into a loud failure instead of a silent one. Managed identity and workload identity remove one entire category of rotation work, since the token Entra ID issues to the app is already short-lived and reissued automatically — there is no credential on the app's side to rotate at all. What is left is rotating the secrets Azure does not manage for you: third-party API keys, externally issued certificates, and database passwords for engines Key Vault does not control directly. Treat each of those as a scheduled task with an owner, not a one-time setup step.

Secret Scanning in CI: GitHub Push Protection#

Everything above reduces how often a real secret needs to exist outside a vault, but it does not make human error impossible — a developer can still paste a live key into a commit. GitHub's secret scanning push protection is the backstop: it inspects each commit as it is pushed and blocks the push outright when it recognizes a supported secret pattern, such as a cloud provider key or a connection string with embedded credentials, before that value ever reaches the remote repository's history. A developer who hits the block can remove the secret and push again or, for a genuine false positive or disposable test data, bypass it with a recorded reason that stays auditable. Wire this in alongside the rest of your pipeline described in CI/CD for .NET with GitHub Actions and Azure DevOps, and pair it with a dependency and vulnerability audit — see the NuGet auditing section of OWASP Top 10 for .NET Developers — since secret scanning and dependency scanning catch two different failure modes.

Best Practices#

  • Never let a real secret exist in appsettings.json, even temporarily "to test something" — use User Secrets locally from the first commit of a project.
  • Prefer managed identity or workload identity over any credential your app has to store, including a Key Vault access key; the best secret is the one that never exists.
  • Set ReloadInterval on the Key Vault configuration provider so rotation does not require a synchronized redeploy across every instance.
  • Scope managed identities narrowly — grant get and list on the specific vault and secrets an app needs, not broad subscription-level access.
  • Add a .env.example with placeholder keys (not values) to a repository instead of a real .env file, and make sure the real one is git-ignored.
  • Enable secret scanning and push protection on every repository, and treat a bypass as something that gets reviewed, not just logged.

Common Pitfalls#

  • Committing a secret, "removing" it in a later commit, and assuming it is gone — it remains recoverable from git history until the repository is rewritten and force-pushed.
  • Using DefaultAzureCredential in production without realizing its fallback chain will silently try developer credentials if the managed identity lookup fails in an unexpected environment.
  • Storing a long-lived client secret for a service principal instead of using managed identity or workload identity, simply because it was the first approach that worked.
  • Forgetting that Kubernetes Secret objects are base64-encoded, not encrypted, and treating them as sufficient protection for a real credential.
  • Never setting ReloadInterval, then discovering during an incident that a rotated Key Vault secret requires a full redeploy to take effect.

Where Should This Secret Live?#

The right store depends on where the code runs, not on how sensitive the secret feels — even a low-risk value benefits from the same discipline, since the process matters more than the individual secret.

EnvironmentRecommended storeWhyRotation
Local developmentUser Secrets (dotnet user-secrets)Keeps secrets out of the project tree and git; not encrypted, so still dev-onlyManual, per developer
CI/CD pipelineThe CI platform's own secret store, injected as environment variables at run timeShort-lived, scoped to the pipeline run; never written into the repositoryManaged by the CI platform
Azure-hosted productionAzure Key Vault plus managed identityNo credential in code or config to fetch the credential in the first placeKey Vault versioning plus ReloadInterval
Kubernetes or AKSWorkload identity (federated Entra ID credential)No long-lived secret stored anywhere in the clusterAutomatic, tied to Entra ID token issuance
Non-Azure hostingKey Vault via an Application ID and X.509 certificate, or the target cloud's equivalent vaultManaged identity is Azure-only; a certificate is the next-best bound credentialCertificate renewal schedule

Frequently Asked Questions#

Are .NET user secrets encrypted?#

No. Secret Manager stores values as plain JSON in a file under the current user's profile directory, outside the project tree so they cannot be committed by accident. It is protected only by ordinary file-system permissions and is explicitly documented as a development convenience, not a secure store — production secrets belong in Key Vault or an equivalent vault, never in the user secrets file.

Is DefaultAzureCredential safe to use in production?#

It is supported in production and widely used there, but it is designed to smooth over the difference between a developer's laptop and a deployed Azure resource by trying several credential sources in sequence. For code that only ever runs as a deployed resource, switching to ManagedIdentityCredential directly is a small change that removes that fallback probing and makes the authentication path more explicit and predictable.

What's actually wrong with putting secrets in environment variables?#

Nothing is wrong with environment variables as a transport — a real secret store commonly injects a short-lived value into one at deploy time. The risk is using them as the permanent, version-controlled home for a secret, since they are visible to anything that can inspect the process (docker inspect, /proc/<pid>/environ, a crash dump) and are inherited by every child process the app spawns.

Do I still need Key Vault if my app uses managed identity?#

Yes, they solve different problems. Managed identity is how the app proves who it is to Azure; Key Vault is where the actual secret material — a database password, a third-party API key — lives. Managed identity removes the need for the app to hold a credential just to reach Key Vault; it does not remove the need for a vault to hold the underlying secret in the first place.

How is workload identity different from a Kubernetes Secret?#

A Kubernetes Secret is a static, base64-encoded value stored in the cluster that you still have to create, distribute and rotate yourself — it is a credential, just an inconvenient one to protect well. Workload identity has the pod exchange a short-lived, automatically rotated federated token for an Entra ID access token, so there is no static secret stored in the cluster at all.

Summary#

  • Configuration providers in .NET are ordered and later ones win, which is what lets the same code read a secret from User Secrets locally and from Key Vault in production without a conditional.
  • User Secrets keeps development-time values out of source control but is not encrypted — it is a convenience, not a vault.
  • Environment variables are a fine transport for injecting a secret at deploy time and a poor permanent home for one.
  • The Azure Key Vault configuration provider, combined with managed identity or workload identity, means the app never has to store a credential just to fetch its other credentials.
  • Workload identity extends the same idea to Kubernetes, replacing a stored client secret with a short-lived federated token.
  • Rotation and CI secret scanning are the safety net for everything else: ReloadInterval for planned rotation, push protection for the mistake that gets committed anyway.

Further Reading#