User Secrets in Development
Keep local API keys and connection strings out of the repository with dotnet user-secrets, and know exactly when they load.
dotnet user-secrets init
dotnet user-secrets set "Payments:ApiKey" "<sandbox-key>"
dotnet user-secrets list
builder.Configuration.AddUserSecrets<Program>();A developer needs a sandbox payment key and a local database password to run the API. Putting them in appsettings.Development.json commits them; putting them in shell profiles leaks them into every process. User secrets is the built-in answer: a JSON file per project, stored in your user profile, outside the source tree.
How it works
dotnet user-secrets init adds a <UserSecretsId> property to the .csproj. The SDK turns it into a UserSecretsIdAttribute on the compiled assembly. The ID is not secret; it only names a folder:
- Windows:
%APPDATA%\Microsoft\UserSecrets\<id>\secrets.json - macOS and Linux:
~/.microsoft/usersecrets/<id>/secrets.json
dotnet user-secrets set "Payments:ApiKey" "..." writes a flattened key into that file; list, remove and clear do what they say. Piping a JSON file into dotnet user-secrets set imports many keys at once.
When they load
The default builders add the secrets file only when the environment is Development and the application assembly carries a UserSecretsIdAttribute. It sits after appsettings.{Environment}.json and before environment variables, so a secret overrides the JSON files and an environment variable overrides the secret. Under Staging, Production or a custom name such as Local, nothing is loaded unless you call AddUserSecrets<Program>() yourself.
What it is not
It is not encryption. The file is plain JSON readable by anything running as your user, including a compromised npm package or a backup tool. It only protects against the commonest leak: committing a key. Use sandbox or local-only credentials, never production ones. It is also not a deployment mechanism: there is no secrets file on a server or in a container, and production values come from environment variables, mounted files or a secret store.
Team decisions
| Choice | Recommendation |
|---|---|
| Where the list of required secrets lives | In the README or as empty keys in appsettings.json, plus options validation that names what is missing |
| API and worker sharing secrets | Give both projects the same UserSecretsId; they then read one file |
| Local database password | A throwaway value for a container on your machine, or Aspire parameters |
Failure modes
Integration tests built with WebApplicationFactory run in Development by default, so they load your secrets on your machine and not on the CI agent: a test that passes locally fails in the pipeline. Make tests supply their own configuration. A second trap is precedence: a Payments__ApiKey variable left in your shell silently overrides the secret you just set, and the app seems to ignore it.
Example
When to use it
- Give each developer their own Stripe-style sandbox key for Payments:ApiKey without it ever appearing in a commit.
- Share one local RabbitMQ password between the Orders API and the Orders worker by giving both the same UserSecretsId.
- Explain why an integration test passes on a laptop but fails in CI: the laptop loaded a connection string from user secrets.
More examples
The day-to-day commands
Run the commands in the project folder or pass <code>--project src/Orders.Api</code>. Keys use colons because they are configuration keys, not environment variable names. <code>list</code> prints values in clear text, so avoid running it while sharing your screen. Importing from a JSON file is the quickest way to onboard a new developer: the team keeps a template with empty values, and each person fills in their own sandbox credentials.
cd src/Orders.Api
dotnet user-secrets init
dotnet user-secrets set "Payments:ApiKey" "<your-sandbox-key>"
dotnet user-secrets set "ConnectionStrings:Shop" "Host=localhost;Database=shop;Username=dev;Password=<local-password>"
dotnet user-secrets list
# Import many keys at once from a JSON file that stays outside the repository.
cat ~/shop-dev/orders-api.secrets.json | dotnet user-secrets set
dotnet user-secrets remove "Payments:ApiKey"
dotnet user-secrets clearThe project file after init
The SDK compiles <code>UserSecretsId</code> into a <code>UserSecretsIdAttribute</code>, which is how the builder finds the file at run time. Giving the API and its worker project the same ID makes them share one <code>secrets.json</code>, which suits services that need the same broker or database credentials. Two unrelated projects that copied a <code>.csproj</code> also share secrets by accident; generate a fresh ID with <code>init</code> in a new project.
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!-- Written by dotnet user-secrets init. Not a secret: it only names the folder. -->
<UserSecretsId>5d0c3f7e-2b8a-4c1e-9f1a-6e2d7b4c9a10</UserSecretsId>
</PropertyGroup>
</Project>Loading secrets outside Development
<code>AddUserSecrets<Program>()</code> reads the <code>UserSecretsIdAttribute</code> from the assembly that contains <code>Program</code>, so it also works from a test project or a console tool that references the API. Guard it with an environment check: calling it unconditionally means a production host would look for a secrets file, and a developer machine running the Release build against production settings would quietly mix in sandbox keys.
var builder = WebApplication.CreateBuilder(args);
// Some teams run locally with ASPNETCORE_ENVIRONMENT=Local and still want their secrets.
if (builder.Environment.IsEnvironment("Local"))
{
builder.Configuration.AddUserSecrets<Program>(optional: true, reloadOnChange: true);
// AddUserSecrets appends, which would put secrets above environment variables
// and arguments. Add those again to keep the default precedence.
builder.Configuration.AddEnvironmentVariables();
builder.Configuration.AddCommandLine(args);
}
var app = builder.Build();
app.MapGet("/health/payments", (IConfiguration config) =>
string.IsNullOrEmpty(config["Payments:ApiKey"])
? Results.Problem("Payments:ApiKey is not configured", statusCode: 503)
: Results.Ok());
app.Run();
public partial class Program;
Discussion